@agent-native/toolkit 0.207.1 → 0.207.2-nightly-20261013011333

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 (77) hide show
  1. package/dist/app/blocks/BlockRenderErrorBoundary.d.ts +34 -0
  2. package/dist/app/blocks/BlockRenderErrorBoundary.d.ts.map +1 -0
  3. package/dist/app/blocks/BlockRenderErrorBoundary.js +52 -0
  4. package/dist/app/blocks/BlockRenderErrorBoundary.js.map +1 -0
  5. package/dist/app/blocks/BlockView.d.ts +2 -1
  6. package/dist/app/blocks/BlockView.d.ts.map +1 -1
  7. package/dist/app/blocks/BlockView.js +5 -4
  8. package/dist/app/blocks/BlockView.js.map +1 -1
  9. package/dist/app/blocks/RegistryBlockDataProvider.d.ts +2 -1
  10. package/dist/app/blocks/RegistryBlockDataProvider.d.ts.map +1 -1
  11. package/dist/app/blocks/RegistryBlockDataProvider.js +5 -3
  12. package/dist/app/blocks/RegistryBlockDataProvider.js.map +1 -1
  13. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  14. package/dist/app/i18n/catalogs/ar-SA.js +2 -0
  15. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  16. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  17. package/dist/app/i18n/catalogs/de-DE.js +2 -0
  18. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  19. package/dist/app/i18n/catalogs/en-US.d.ts +2 -0
  20. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  21. package/dist/app/i18n/catalogs/en-US.js +2 -0
  22. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  23. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  24. package/dist/app/i18n/catalogs/es-ES.js +2 -0
  25. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  26. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  27. package/dist/app/i18n/catalogs/fr-FR.js +2 -0
  28. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  29. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  30. package/dist/app/i18n/catalogs/hi-IN.js +2 -0
  31. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  32. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  33. package/dist/app/i18n/catalogs/ja-JP.js +2 -0
  34. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  35. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/ko-KR.js +2 -0
  37. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  38. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/pt-BR.js +2 -0
  40. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  41. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  42. package/dist/app/i18n/catalogs/zh-CN.js +2 -0
  43. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  44. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  45. package/dist/app/i18n/catalogs/zh-TW.js +2 -0
  46. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  47. package/dist/app/org/OrgSwitcher.d.ts.map +1 -1
  48. package/dist/app/org/OrgSwitcher.js +65 -8
  49. package/dist/app/org/OrgSwitcher.js.map +1 -1
  50. package/dist/editor/RegistryBlockNode.d.ts.map +1 -1
  51. package/dist/editor/RegistryBlockNode.js +5 -4
  52. package/dist/editor/RegistryBlockNode.js.map +1 -1
  53. package/dist/ui/slider.d.ts +3 -1
  54. package/dist/ui/slider.d.ts.map +1 -1
  55. package/dist/ui/slider.js +1 -1
  56. package/dist/ui/slider.js.map +1 -1
  57. package/package.json +4 -4
  58. package/src/app/blocks/BlockRenderErrorBoundary.spec.tsx +225 -0
  59. package/src/app/blocks/BlockRenderErrorBoundary.tsx +162 -0
  60. package/src/app/blocks/BlockView.tsx +66 -26
  61. package/src/app/blocks/RegistryBlockDataProvider.spec.tsx +67 -0
  62. package/src/app/blocks/RegistryBlockDataProvider.tsx +65 -24
  63. package/src/app/i18n/catalogs/ar-SA.ts +2 -0
  64. package/src/app/i18n/catalogs/de-DE.ts +2 -0
  65. package/src/app/i18n/catalogs/en-US.ts +2 -0
  66. package/src/app/i18n/catalogs/es-ES.ts +2 -0
  67. package/src/app/i18n/catalogs/fr-FR.ts +2 -0
  68. package/src/app/i18n/catalogs/hi-IN.ts +2 -0
  69. package/src/app/i18n/catalogs/ja-JP.ts +2 -0
  70. package/src/app/i18n/catalogs/ko-KR.ts +2 -0
  71. package/src/app/i18n/catalogs/pt-BR.ts +2 -0
  72. package/src/app/i18n/catalogs/zh-CN.ts +2 -0
  73. package/src/app/i18n/catalogs/zh-TW.ts +2 -0
  74. package/src/app/org/OrgSwitcher.spec.tsx +103 -2
  75. package/src/app/org/OrgSwitcher.tsx +73 -8
  76. package/src/editor/RegistryBlockNode.tsx +33 -17
  77. package/src/ui/slider.tsx +8 -3
@@ -97,4 +97,71 @@ describe("RegistryBlockDataProvider", () => {
97
97
  "Portable UI, Core registry",
98
98
  );
99
99
  });
100
+
101
+ it("contains a registered block renderer failure to its block", () => {
102
+ const registry = new BlockRegistry();
103
+ registry.register(
104
+ defineBlock({
105
+ type: "note",
106
+ schema: z.object({ body: z.string() }),
107
+ mdx: {
108
+ tag: "Note",
109
+ toAttrs: (data) => ({ body: data.body }),
110
+ fromAttrs: (attrs) => ({ body: attrs.string("body") ?? "" }),
111
+ },
112
+ Read: () => {
113
+ throw new Error("renderer failed");
114
+ },
115
+ placement: ["block"],
116
+ label: "Note",
117
+ description: "A note.",
118
+ }),
119
+ );
120
+
121
+ const block: TestBlock = {
122
+ id: "note-2",
123
+ type: "note",
124
+ data: { body: "unreadable" },
125
+ };
126
+ const value: RegistryBlockDataValue<TestBlock> = {
127
+ editable: false,
128
+ getBlock: (id) => (id === block.id ? block : undefined),
129
+ onBlockDataChange: vi.fn(),
130
+ };
131
+ const logError = vi.spyOn(console, "error").mockImplementation(() => {});
132
+
133
+ function Probe() {
134
+ const data = useRegistryBlockData<TestBlock>();
135
+ return (
136
+ data?.renderRegisteredBlock?.(block, {
137
+ blockType: "note",
138
+ editable: false,
139
+ selected: false,
140
+ shellHovered: false,
141
+ panelOpen: false,
142
+ setPanelOpen: vi.fn(),
143
+ onChange: vi.fn(),
144
+ })?.body ?? null
145
+ );
146
+ }
147
+
148
+ try {
149
+ act(() => {
150
+ root.render(
151
+ <BlockRegistryProvider registry={registry} ctx={{}}>
152
+ <RegistryBlockDataProvider value={value} interactive>
153
+ <Probe />
154
+ </RegistryBlockDataProvider>
155
+ </BlockRegistryProvider>,
156
+ );
157
+ });
158
+
159
+ const fallback = container.querySelector("[data-block-render-error]");
160
+ expect(fallback).not.toBeNull();
161
+ expect(fallback?.textContent).not.toContain("renderer failed");
162
+ expect(fallback?.hasAttribute("data-plan-interactive")).toBe(true);
163
+ } finally {
164
+ logError.mockRestore();
165
+ }
166
+ });
100
167
  });
@@ -5,8 +5,13 @@ import {
5
5
  type RegistryBlockSideMapBlock,
6
6
  } from "@agent-native/toolkit/editor/RegistryBlockContext";
7
7
  import { IconPencil } from "@tabler/icons-react";
8
+ import { Suspense } from "react";
8
9
  import { useCallback, useMemo, type ReactNode } from "react";
9
10
 
11
+ import {
12
+ BlockRenderErrorBoundary,
13
+ BlockRenderSuspenseFallback,
14
+ } from "./BlockRenderErrorBoundary.js";
10
15
  import { blockEditSurface } from "./BlockView.js";
11
16
  import { useOptionalBlockRegistry } from "./provider.js";
12
17
  import { SchemaBlockEditor } from "./SchemaBlockEditor.js";
@@ -16,9 +21,11 @@ export function RegistryBlockDataProvider<
16
21
  >({
17
22
  value,
18
23
  children,
24
+ interactive = false,
19
25
  }: {
20
26
  value: RegistryBlockDataValue<TBlock>;
21
27
  children: ReactNode;
28
+ interactive?: boolean;
22
29
  }) {
23
30
  const registryValue = useOptionalBlockRegistry();
24
31
  const renderRegisteredBlock = useCallback(
@@ -35,37 +42,71 @@ export function RegistryBlockDataProvider<
35
42
  const blockData = block.data;
36
43
  const Read = spec.Read;
37
44
  let body: ReactNode = (
38
- <Read
39
- data={blockData}
45
+ <BlockRenderErrorBoundary
40
46
  blockId={block.id}
41
- title={block.title}
42
- summary={block.summary}
43
- ctx={registryValue.ctx}
44
- />
47
+ blockType={blockType}
48
+ resetKey={blockData}
49
+ interactive={interactive}
50
+ >
51
+ <Suspense
52
+ fallback={
53
+ <BlockRenderSuspenseFallback
54
+ blockId={block.id}
55
+ blockType={blockType}
56
+ />
57
+ }
58
+ >
59
+ <Read
60
+ data={blockData}
61
+ blockId={block.id}
62
+ title={block.title}
63
+ summary={block.summary}
64
+ ctx={registryValue.ctx}
65
+ />
66
+ </Suspense>
67
+ </BlockRenderErrorBoundary>
45
68
  );
46
69
  let editSurface: ReactNode = null;
47
70
  const canEditBlock = options.editable && spec.placement.includes("block");
48
71
  if (canEditBlock) {
49
72
  const Edit = spec.Edit;
50
- const editorNode = Edit ? (
51
- <Edit
52
- data={blockData}
53
- onChange={options.onChange}
54
- editable
55
- blockId={block.id}
56
- title={block.title}
57
- summary={block.summary}
58
- ctx={registryValue.ctx}
59
- />
60
- ) : (
61
- <SchemaBlockEditor
62
- data={blockData}
63
- onChange={(nextData) => options.onChange(nextData)}
64
- schema={spec.schema}
65
- editable
73
+ const editorNode = (
74
+ <BlockRenderErrorBoundary
66
75
  blockId={block.id}
67
- ctx={registryValue.ctx}
68
- />
76
+ blockType={blockType}
77
+ resetKey={blockData}
78
+ interactive={interactive}
79
+ >
80
+ <Suspense
81
+ fallback={
82
+ <BlockRenderSuspenseFallback
83
+ blockId={block.id}
84
+ blockType={blockType}
85
+ />
86
+ }
87
+ >
88
+ {Edit ? (
89
+ <Edit
90
+ data={blockData}
91
+ onChange={options.onChange}
92
+ editable
93
+ blockId={block.id}
94
+ title={block.title}
95
+ summary={block.summary}
96
+ ctx={registryValue.ctx}
97
+ />
98
+ ) : (
99
+ <SchemaBlockEditor
100
+ data={blockData}
101
+ onChange={(nextData) => options.onChange(nextData)}
102
+ schema={spec.schema}
103
+ editable
104
+ blockId={block.id}
105
+ ctx={registryValue.ctx}
106
+ />
107
+ )}
108
+ </Suspense>
109
+ </BlockRenderErrorBoundary>
69
110
  );
70
111
  const surface = blockEditSurface(spec);
71
112
  if (surface === "panel" && registryValue.ctx.renderEditSurface) {
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "هذه الكتلة غير متاحة مؤقتًا.",
5
+ "block.loading": "جارٍ تحميل هذه الكتلة.",
4
6
  "composer.contextUrlLabel": "عنوان URL",
5
7
  "composer.contextInvalidUrl": "أدخل عنوان URL صالحًا يبدأ بـ HTTP أو HTTPS.",
6
8
  "composer.contextAttach": "إرفاق",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "Dieser Block ist vorübergehend nicht verfügbar.",
5
+ "block.loading": "Block wird geladen.",
4
6
  "composer.contextUrlLabel": "URL-Adresse",
5
7
  "composer.contextInvalidUrl": "Gib eine gültige HTTP- oder HTTPS-URL ein.",
6
8
  "composer.contextAttach": "Anhängen",
@@ -1,4 +1,6 @@
1
1
  const messages = {
2
+ "block.renderUnavailable": "This block is temporarily unavailable.",
3
+ "block.loading": "Loading block.",
2
4
  "composer.contextUrlLabel": "URL",
3
5
  "composer.contextInvalidUrl": "Enter a valid HTTP or HTTPS URL.",
4
6
  "composer.contextAttach": "Attach",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "Este bloque no está disponible temporalmente.",
5
+ "block.loading": "Cargando bloque.",
4
6
  "composer.contextUrlLabel": "Dirección URL",
5
7
  "composer.contextInvalidUrl": "Introduce una URL HTTP o HTTPS válida.",
6
8
  "composer.contextAttach": "Adjuntar",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "Ce bloc est temporairement indisponible.",
5
+ "block.loading": "Chargement du bloc.",
4
6
  "composer.contextUrlLabel": "Adresse URL",
5
7
  "composer.contextInvalidUrl": "Saisissez une URL HTTP ou HTTPS valide.",
6
8
  "composer.contextAttach": "Joindre",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "यह ब्लॉक अस्थायी रूप से उपलब्ध नहीं है।",
5
+ "block.loading": "ब्लॉक लोड हो रहा है।",
4
6
  "composer.contextUrlLabel": "यूआरएल",
5
7
  "composer.contextInvalidUrl": "मान्य HTTP या HTTPS यूआरएल दर्ज करें।",
6
8
  "composer.contextAttach": "अटैच करें",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "このブロックは一時的に利用できません。",
5
+ "block.loading": "ブロックを読み込み中です。",
4
6
  "composer.contextUrlLabel": "URL",
5
7
  "composer.contextInvalidUrl":
6
8
  "有効な HTTP または HTTPS の URL を入力してください。",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "이 블록은 일시적으로 사용할 수 없습니다.",
5
+ "block.loading": "블록을 불러오는 중입니다.",
4
6
  "composer.contextUrlLabel": "URL",
5
7
  "composer.contextInvalidUrl": "유효한 HTTP 또는 HTTPS URL을 입력하세요.",
6
8
  "composer.contextAttach": "첨부",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "Este bloco está temporariamente indisponível.",
5
+ "block.loading": "Carregando bloco.",
4
6
  "composer.contextUrlLabel": "Endereço URL",
5
7
  "composer.contextInvalidUrl": "Insira uma URL HTTP ou HTTPS válida.",
6
8
  "composer.contextAttach": "Anexar",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "此区块暂时不可用。",
5
+ "block.loading": "正在加载区块。",
4
6
  "composer.contextUrlLabel": "网址",
5
7
  "composer.contextInvalidUrl": "请输入有效的 HTTP 或 HTTPS 网址。",
6
8
  "composer.contextAttach": "附加",
@@ -1,6 +1,8 @@
1
1
  import type { ToolkitAgentChatTranslation } from "../types.js";
2
2
 
3
3
  const messages: ToolkitAgentChatTranslation = {
4
+ "block.renderUnavailable": "此區塊暫時無法使用。",
5
+ "block.loading": "正在載入區塊。",
4
6
  "composer.contextUrlLabel": "網址",
5
7
  "composer.contextInvalidUrl": "請輸入有效的 HTTP 或 HTTPS 網址。",
6
8
  "composer.contextAttach": "附加",
@@ -11,8 +11,13 @@ const mocks = vi.hoisted(() => {
11
11
  variables: undefined as unknown,
12
12
  mutate: vi.fn(),
13
13
  reset: vi.fn(),
14
+ onPersisted: undefined as ((orgId: string | null) => void) | undefined,
14
15
  });
15
- type MutateOptions = { onSuccess?: () => void; onSettled?: () => void };
16
+ type MutateOptions = {
17
+ onError?: () => void;
18
+ onSuccess?: () => void;
19
+ onSettled?: () => void;
20
+ };
16
21
  const succeed = (_value: unknown, options?: MutateOptions) => {
17
22
  options?.onSuccess?.();
18
23
  options?.onSettled?.();
@@ -47,7 +52,12 @@ vi.mock("@agent-native/core/client/org", () => ({
47
52
  useCreateOrg: () => mocks.createOrg,
48
53
  useJoinByDomain: () => mocks.joinByDomain,
49
54
  useOrg: mocks.useOrg,
50
- useSwitchOrg: () => mocks.switchOrg,
55
+ useSwitchOrg: (options?: {
56
+ onPersisted?: (orgId: string | null) => void;
57
+ }) => {
58
+ mocks.switchOrg.onPersisted = options?.onPersisted;
59
+ return mocks.switchOrg;
60
+ },
51
61
  }));
52
62
 
53
63
  vi.mock("@agent-native/core/client/hooks", () => ({
@@ -118,6 +128,7 @@ describe("OrgSwitcher (account menu)", () => {
118
128
  mutation.mutate.mockReset();
119
129
  mutation.mutate.mockImplementation(mocks.succeed);
120
130
  mutation.reset.mockReset();
131
+ mutation.onPersisted = undefined;
121
132
  }
122
133
  mocks.useSession.mockReturnValue({
123
134
  session: { email: "owner@example.com", name: "Session Name" },
@@ -769,6 +780,96 @@ describe("OrgSwitcher (account menu)", () => {
769
780
  expect(trigger().getAttribute("aria-expanded")).toBe("false");
770
781
  });
771
782
 
783
+ it("closes explicit selections on persistence and leaves reopened menus open", async () => {
784
+ mocks.useOrg.mockReturnValue({ data: ownerOrg, isLoading: false });
785
+ let currentOptions: Parameters<typeof mocks.succeed>[1] = undefined;
786
+ mocks.switchOrg.mutate.mockImplementationOnce((_orgId, options) => {
787
+ currentOptions = options;
788
+ });
789
+
790
+ render(<OrgSwitcher />);
791
+ openMenu();
792
+ await select("Acme");
793
+
794
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
795
+ act(() => mocks.switchOrg.onPersisted?.("org-1"));
796
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
797
+ expect(currentOptions?.onSuccess).toBeDefined();
798
+
799
+ openMenu();
800
+ act(() => currentOptions?.onSuccess?.());
801
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
802
+
803
+ let otherOptions: Parameters<typeof mocks.succeed>[1] = undefined;
804
+ mocks.switchOrg.mutate.mockImplementationOnce((_orgId, options) => {
805
+ otherOptions = options;
806
+ });
807
+ await select("Globex");
808
+
809
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
810
+ act(() => mocks.switchOrg.onPersisted?.("org-2"));
811
+ expect(trigger().getAttribute("aria-expanded")).toBe("false");
812
+ openMenu();
813
+ act(() => otherOptions?.onSuccess?.());
814
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
815
+ });
816
+
817
+ it("retries recovery after a failed Personal selection on the same mount", async () => {
818
+ mocks.useOrg.mockReturnValue({
819
+ data: { ...ownerOrg, orgId: null, orgName: null, role: null },
820
+ isLoading: false,
821
+ });
822
+ const attempts: Array<Parameters<typeof mocks.succeed>[1]> = [];
823
+ mocks.switchOrg.mutate.mockImplementation((_orgId, options) => {
824
+ attempts.push(options);
825
+ });
826
+
827
+ render(<OrgSwitcher />);
828
+ expect(attempts).toHaveLength(1);
829
+ openMenu();
830
+
831
+ act(() => attempts[0]?.onError?.());
832
+ act(() => attempts[0]?.onSettled?.());
833
+ expect(attempts).toHaveLength(1);
834
+
835
+ await select("Acme");
836
+ expect(attempts).toHaveLength(2);
837
+ act(() => attempts[1]?.onError?.());
838
+
839
+ expect(attempts).toHaveLength(3);
840
+ expect(attempts[2]?.onSuccess).toBeUndefined();
841
+ expect(attempts[2]?.onSettled).toBeDefined();
842
+
843
+ act(() => attempts[2]?.onError?.());
844
+ act(() => attempts[2]?.onSettled?.());
845
+ expect(attempts).toHaveLength(3);
846
+ });
847
+
848
+ it("keeps same-target personal recovery separate from explicit selection", async () => {
849
+ mocks.useOrg.mockReturnValue({
850
+ data: {
851
+ ...ownerOrg,
852
+ orgId: null,
853
+ orgName: null,
854
+ role: null,
855
+ },
856
+ isLoading: false,
857
+ });
858
+ mocks.switchOrg.mutate.mockImplementationOnce(() => {});
859
+
860
+ render(<OrgSwitcher />);
861
+ expect(mocks.switchOrg.mutate).toHaveBeenCalledWith(
862
+ "org-1",
863
+ expect.objectContaining({ onError: expect.any(Function) }),
864
+ );
865
+ openMenu();
866
+ await select("Acme");
867
+
868
+ expect(mocks.switchOrg.mutate).toHaveBeenCalledTimes(1);
869
+ act(() => mocks.switchOrg.onPersisted?.("org-1"));
870
+ expect(trigger().getAttribute("aria-expanded")).toBe("true");
871
+ });
872
+
772
873
  it("accepts invitations and joins domain matches from the menu", async () => {
773
874
  mocks.useOrg.mockReturnValue({
774
875
  data: {
@@ -475,7 +475,27 @@ export function OrgSwitcher({
475
475
  const { session } = useSession();
476
476
  const { enabled: demoModeEnabled } = useDemoModeStatus();
477
477
  const t = useT();
478
- const switchOrg = useSwitchOrg();
478
+ const [open, setOpen] = useState(false);
479
+ const pendingOrgSwitchRef = useRef<
480
+ | { kind: "explicit"; orgId: string; closedEarly: boolean }
481
+ | { kind: "personal-recovery"; orgId: string }
482
+ | null
483
+ >(null);
484
+ const switchOrg = useSwitchOrg({
485
+ onPersisted: (orgId) => {
486
+ const pendingSwitch = pendingOrgSwitchRef.current;
487
+ if (
488
+ orgId === null ||
489
+ pendingSwitch?.kind !== "explicit" ||
490
+ pendingSwitch.orgId !== orgId ||
491
+ pendingSwitch.closedEarly
492
+ ) {
493
+ return;
494
+ }
495
+ pendingSwitch.closedEarly = true;
496
+ setOpen(false);
497
+ },
498
+ });
479
499
  const createOrg = useCreateOrg();
480
500
  const acceptInvitation = useAcceptInvitation();
481
501
  const joinByDomain = useJoinByDomain();
@@ -487,7 +507,6 @@ export function OrgSwitcher({
487
507
  { enabled: !!email },
488
508
  );
489
509
  const avatarUrl = useAvatarUrl(email);
490
- const [open, setOpen] = useState(false);
491
510
  const [view, setView] = useState<MenuView>("main");
492
511
  const [createOpen, setCreateOpen] = useState(false);
493
512
  const [newName, setNewName] = useState("");
@@ -513,22 +532,49 @@ export function OrgSwitcher({
513
532
  // Accounts that picked the retired "Personal" choice have `orgId: null`
514
533
  // stored while still holding memberships, which strands them outside the
515
534
  // org-scoped Builder.io connection and vault credentials. Move them back.
516
- // A failed switch retries on the next org fetch, not on the next render:
517
- // the mutation's state changes would otherwise re-run this in a tight loop.
535
+ // Automatic recovery failures wait for the next org fetch to avoid a loop
536
+ // on mutation state changes. A failed explicit switch from Personal gets one
537
+ // recovery pass because this effect may have skipped the pending selection.
518
538
  const personalRecoveryRef = useRef(false);
539
+ const [personalRecoveryRetryEpoch, setPersonalRecoveryRetryEpoch] =
540
+ useState(0);
519
541
  const firstMembershipId = org?.orgs?.[0]?.orgId ?? null;
520
542
  const switchOrgMutate = switchOrg.mutate;
521
543
  useEffect(() => {
522
- if (org?.orgId || !firstMembershipId || personalRecoveryRef.current) {
544
+ if (
545
+ org?.orgId ||
546
+ !firstMembershipId ||
547
+ personalRecoveryRef.current ||
548
+ pendingOrgSwitchRef.current
549
+ ) {
523
550
  return;
524
551
  }
525
552
  personalRecoveryRef.current = true;
553
+ const recovery = {
554
+ kind: "personal-recovery" as const,
555
+ orgId: firstMembershipId,
556
+ };
557
+ pendingOrgSwitchRef.current = recovery;
526
558
  switchOrgMutate(firstMembershipId, {
527
559
  onError: () => {
528
560
  personalRecoveryRef.current = false;
561
+ if (pendingOrgSwitchRef.current === recovery) {
562
+ pendingOrgSwitchRef.current = null;
563
+ }
564
+ },
565
+ onSettled: () => {
566
+ if (pendingOrgSwitchRef.current === recovery) {
567
+ pendingOrgSwitchRef.current = null;
568
+ }
529
569
  },
530
570
  });
531
- }, [org?.orgId, firstMembershipId, switchOrgMutate, dataUpdatedAt]);
571
+ }, [
572
+ org?.orgId,
573
+ firstMembershipId,
574
+ switchOrgMutate,
575
+ dataUpdatedAt,
576
+ personalRecoveryRetryEpoch,
577
+ ]);
532
578
 
533
579
  const showView = (next: MenuView) => {
534
580
  viewChangedRef.current = true;
@@ -670,8 +716,27 @@ export function OrgSwitcher({
670
716
  );
671
717
 
672
718
  const selectOrg = (orgId: string) => {
673
- if (switchOrg.isPending) return;
674
- switchOrg.mutate(orgId, { onSuccess: () => setOpen(false) });
719
+ if (switchOrg.isPending || pendingOrgSwitchRef.current) return;
720
+ const selectedFromPersonal = org?.orgId == null;
721
+ const selection = { kind: "explicit" as const, orgId, closedEarly: false };
722
+ pendingOrgSwitchRef.current = selection;
723
+ switchOrg.mutate(orgId, {
724
+ onError: () => {
725
+ if (pendingOrgSwitchRef.current === selection) {
726
+ pendingOrgSwitchRef.current = null;
727
+ if (selectedFromPersonal && !selection.closedEarly) {
728
+ personalRecoveryRef.current = false;
729
+ setPersonalRecoveryRetryEpoch((epoch) => epoch + 1);
730
+ }
731
+ }
732
+ },
733
+ onSuccess: () => {
734
+ if (pendingOrgSwitchRef.current === selection) {
735
+ pendingOrgSwitchRef.current = null;
736
+ }
737
+ if (!selection.closedEarly) setOpen(false);
738
+ },
739
+ });
675
740
  };
676
741
  const isSwitchingTo = (orgId: string) =>
677
742
  switchOrg.isPending && switchOrg.variables === orgId;
@@ -20,6 +20,10 @@ import {
20
20
  type MouseEvent as ReactMouseEvent,
21
21
  } from "react";
22
22
 
23
+ import {
24
+ BlockRenderErrorFallback,
25
+ BlockRenderErrorBoundary,
26
+ } from "../app/blocks/BlockRenderErrorBoundary.js";
23
27
  import {
24
28
  useRegistryBlockData,
25
29
  type RegistryBlockDataChangeMeta,
@@ -178,9 +182,12 @@ export function RegistryBlockNodeView(props: NodeViewProps) {
178
182
  selectNode(event, true)
179
183
  }
180
184
  >
181
- <RegistryBlockLoadErrorView
185
+ <BlockRenderErrorFallback
186
+ blockId={block.id}
187
+ blockType={blockType}
182
188
  message={block.loadError.message}
183
189
  rawSource={block.loadError.rawSource}
190
+ interactive
184
191
  />
185
192
  </NodeViewWrapper>
186
193
  );
@@ -206,8 +213,26 @@ export function RegistryBlockNodeView(props: NodeViewProps) {
206
213
  onChange: commitBlockData,
207
214
  });
208
215
  if (registered) {
209
- body = registered.body;
210
- editSurface = registered.editSurface ?? null;
216
+ body = (
217
+ <BlockRenderErrorBoundary
218
+ blockId={block.id}
219
+ blockType={blockType}
220
+ resetKey={block.data}
221
+ interactive
222
+ >
223
+ {registered.body}
224
+ </BlockRenderErrorBoundary>
225
+ );
226
+ editSurface = registered.editSurface ? (
227
+ <BlockRenderErrorBoundary
228
+ blockId={block.id}
229
+ blockType={blockType}
230
+ resetKey={block.data}
231
+ interactive
232
+ >
233
+ {registered.editSurface}
234
+ </BlockRenderErrorBoundary>
235
+ ) : null;
211
236
  } else if (sideMap?.renderLegacyBlock) {
212
237
  const selfEdits =
213
238
  editable && Boolean(sideMap.legacyBlockSelfEdits?.(blockType));
@@ -281,20 +306,11 @@ export function RegistryBlockLoadErrorView({
281
306
  rawSource?: string;
282
307
  }) {
283
308
  return (
284
- <div
285
- contentEditable={false}
286
- data-plan-interactive
287
- className="plan-block-node__load-error space-y-2 rounded-md border border-border px-3 py-2 text-sm"
288
- >
289
- {rawSource ? (
290
- <pre className="overflow-auto whitespace-pre-wrap font-mono text-xs text-foreground">
291
- {rawSource}
292
- </pre>
293
- ) : null}
294
- <p role="alert" className="text-muted-foreground">
295
- {message}
296
- </p>
297
- </div>
309
+ <BlockRenderErrorFallback
310
+ message={message}
311
+ rawSource={rawSource}
312
+ interactive
313
+ />
298
314
  );
299
315
  }
300
316
 
package/src/ui/slider.tsx CHANGED
@@ -5,8 +5,10 @@ import { cn } from "../utils.js";
5
5
 
6
6
  const Slider = React.forwardRef<
7
7
  React.ElementRef<typeof SliderPrimitive.Root>,
8
- React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
9
- >(({ className, ...props }, ref) => (
8
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
9
+ thumbAriaLabel?: string;
10
+ }
11
+ >(({ className, thumbAriaLabel, ...props }, ref) => (
10
12
  <SliderPrimitive.Root
11
13
  ref={ref}
12
14
  className={cn(
@@ -18,7 +20,10 @@ const Slider = React.forwardRef<
18
20
  <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
19
21
  <SliderPrimitive.Range className="absolute h-full bg-primary" />
20
22
  </SliderPrimitive.Track>
21
- <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
23
+ <SliderPrimitive.Thumb
24
+ aria-label={thumbAriaLabel}
25
+ className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
26
+ />
22
27
  </SliderPrimitive.Root>
23
28
  ));
24
29
  Slider.displayName = SliderPrimitive.Root.displayName;