@btst/stack 3.0.2 → 3.1.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.
Files changed (76) hide show
  1. package/dist/context/index.d.cts +3 -1
  2. package/dist/context/index.d.mts +3 -1
  3. package/dist/context/index.d.ts +3 -1
  4. package/dist/packages/stack/src/context/auth.cjs +20 -14
  5. package/dist/packages/stack/src/context/auth.mjs +20 -14
  6. package/dist/packages/stack/src/context/provider.cjs +42 -22
  7. package/dist/packages/stack/src/context/provider.mjs +43 -23
  8. package/dist/packages/stack/src/plugins/blog/client/components/pages/home-page.cjs +32 -28
  9. package/dist/packages/stack/src/plugins/blog/client/components/pages/home-page.mjs +32 -29
  10. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.cjs +53 -47
  11. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.mjs +53 -48
  12. package/dist/packages/stack/src/plugins/blog/client/components/pages/tag-page.cjs +30 -24
  13. package/dist/packages/stack/src/plugins/blog/client/components/pages/tag-page.mjs +30 -25
  14. package/dist/plugins/blog/api/index.d.cts +2 -2
  15. package/dist/plugins/blog/api/index.d.mts +2 -2
  16. package/dist/plugins/blog/api/index.d.ts +2 -2
  17. package/dist/plugins/blog/client/components/index.d.cts +6 -10
  18. package/dist/plugins/blog/client/components/index.d.mts +6 -10
  19. package/dist/plugins/blog/client/components/index.d.ts +6 -10
  20. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  21. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  22. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  23. package/dist/plugins/blog/client/index.d.cts +2 -2
  24. package/dist/plugins/blog/client/index.d.mts +2 -2
  25. package/dist/plugins/blog/client/index.d.ts +2 -2
  26. package/dist/plugins/blog/client/pages/post.cjs +9 -0
  27. package/dist/plugins/blog/client/pages/post.d.cts +8 -0
  28. package/dist/plugins/blog/client/pages/post.d.mts +8 -0
  29. package/dist/plugins/blog/client/pages/post.d.ts +8 -0
  30. package/dist/plugins/blog/client/pages/post.mjs +7 -0
  31. package/dist/plugins/blog/client/pages/posts.cjs +9 -0
  32. package/dist/plugins/blog/client/pages/posts.d.cts +8 -0
  33. package/dist/plugins/blog/client/pages/posts.d.mts +8 -0
  34. package/dist/plugins/blog/client/pages/posts.d.ts +8 -0
  35. package/dist/plugins/blog/client/pages/posts.mjs +7 -0
  36. package/dist/plugins/blog/client/pages/tag.cjs +9 -0
  37. package/dist/plugins/blog/client/pages/tag.d.cts +8 -0
  38. package/dist/plugins/blog/client/pages/tag.d.mts +8 -0
  39. package/dist/plugins/blog/client/pages/tag.d.ts +8 -0
  40. package/dist/plugins/blog/client/pages/tag.mjs +7 -0
  41. package/dist/plugins/blog/query-keys.d.cts +2 -2
  42. package/dist/plugins/blog/query-keys.d.mts +2 -2
  43. package/dist/plugins/blog/query-keys.d.ts +2 -2
  44. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  45. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  46. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  47. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  48. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  49. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  50. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  51. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  52. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  53. package/dist/shared/{stack.BTStHVTy.d.cts → stack.BGzgFx3R.d.cts} +5 -5
  54. package/dist/shared/{stack.94kN3pEK.d.mts → stack.CjCSDfWy.d.mts} +5 -5
  55. package/dist/shared/{stack.lPKwd_v0.d.ts → stack.Clil75lW.d.ts} +37 -37
  56. package/dist/shared/{stack.CtrnoQgH.d.cts → stack.DNw6Hv5o.d.cts} +37 -37
  57. package/dist/shared/{stack.BggTHKlo.d.mts → stack.DhZtP2JZ.d.mts} +37 -37
  58. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.Dpg_JjIo.d.ts} +5 -5
  59. package/package.json +41 -2
  60. package/src/__tests__/provider-hydration.test.tsx +164 -0
  61. package/src/context/auth.tsx +21 -14
  62. package/src/context/provider.tsx +51 -28
  63. package/src/plugins/blog/__tests__/page-load-error.test.tsx +53 -0
  64. package/src/plugins/blog/__tests__/page-loading.test.tsx +106 -0
  65. package/src/plugins/blog/client/components/pages/home-page.tsx +39 -35
  66. package/src/plugins/blog/client/components/pages/post-page.tsx +62 -54
  67. package/src/plugins/blog/client/components/pages/tag-page.tsx +35 -29
  68. package/src/plugins/blog/client/pages/post.tsx +7 -0
  69. package/src/plugins/blog/client/pages/posts.tsx +7 -0
  70. package/src/plugins/blog/client/pages/tag.tsx +7 -0
  71. package/dist/shared/{stack.D20bOqfr.d.ts → stack.BuxUQqL8.d.ts} +20 -20
  72. package/dist/shared/{stack.AWSs6TXN.d.cts → stack.CX83YNuS.d.cts} +20 -20
  73. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  74. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  75. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
  76. package/dist/shared/{stack.HB5uGLpE.d.mts → stack.Egr3RWhA.d.mts} +20 -20
@@ -2,7 +2,7 @@ import * as _btst_stack_plugins_client from '@btst/stack/plugins/client';
2
2
  import { createApiClient } from '@btst/stack/plugins/client';
3
3
  import * as zod from 'zod';
4
4
  import { z } from 'zod';
5
- import { c as createPostSchema, u as updatePostSchema } from './stack.CKc_Bais.mjs';
5
+ import { c as createPostSchema, u as updatePostSchema } from './stack.Csi-4Mgz.mjs';
6
6
  import { P as Post, T as Tag, S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.mjs';
7
7
  import * as _btst_stack_plugins_api from '@btst/stack/plugins/api';
8
8
  import { DeepReadonly, OperationContext, OperationErrorContext } from '@btst/stack/plugins/api';
@@ -89,12 +89,6 @@ declare const NextPreviousPostsQuerySchema: z.ZodObject<{
89
89
  date: z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>;
90
90
  }, z.core.$strip>;
91
91
  declare const CreatePostOperationInputSchema: z.ZodObject<{
92
- slug: z.ZodOptional<z.ZodString>;
93
- title: z.ZodString;
94
- content: z.ZodString;
95
- excerpt: z.ZodString;
96
- image: z.ZodOptional<z.ZodString>;
97
- published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
98
92
  tags: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
99
93
  name: z.ZodString;
100
94
  }, z.core.$strip>, z.ZodObject<{
@@ -102,6 +96,12 @@ declare const CreatePostOperationInputSchema: z.ZodObject<{
102
96
  name: z.ZodString;
103
97
  slug: z.ZodString;
104
98
  }, z.core.$strip>]>>>>;
99
+ slug: z.ZodOptional<z.ZodString>;
100
+ title: z.ZodString;
101
+ content: z.ZodString;
102
+ excerpt: z.ZodString;
103
+ image: z.ZodOptional<z.ZodString>;
104
+ published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
105
105
  publishedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
106
106
  createdAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
107
107
  updatedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
@@ -109,12 +109,6 @@ declare const CreatePostOperationInputSchema: z.ZodObject<{
109
109
  declare const UpdatePostOperationInputSchema: z.ZodObject<{
110
110
  id: z.ZodString;
111
111
  data: z.ZodObject<{
112
- slug: z.ZodString;
113
- title: z.ZodString;
114
- content: z.ZodString;
115
- excerpt: z.ZodString;
116
- image: z.ZodOptional<z.ZodString>;
117
- published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
118
112
  tags: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
119
113
  name: z.ZodString;
120
114
  }, z.core.$strip>, z.ZodObject<{
@@ -122,6 +116,12 @@ declare const UpdatePostOperationInputSchema: z.ZodObject<{
122
116
  name: z.ZodString;
123
117
  slug: z.ZodString;
124
118
  }, z.core.$strip>]>>>>;
119
+ slug: z.ZodString;
120
+ title: z.ZodString;
121
+ content: z.ZodString;
122
+ excerpt: z.ZodString;
123
+ image: z.ZodOptional<z.ZodString>;
124
+ published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
125
125
  publishedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
126
126
  createdAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
127
127
  updatedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
@@ -386,9 +386,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
386
386
  title: string;
387
387
  content: string;
388
388
  excerpt: string;
389
- slug?: string | undefined;
390
- image?: string | undefined;
391
- published?: boolean | undefined;
392
389
  tags?: ({
393
390
  name: string;
394
391
  } | {
@@ -396,6 +393,9 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
396
393
  name: string;
397
394
  slug: string;
398
395
  })[] | undefined;
396
+ slug?: string | undefined;
397
+ image?: string | undefined;
398
+ published?: boolean | undefined;
399
399
  publishedAt?: unknown;
400
400
  createdAt?: unknown;
401
401
  updatedAt?: unknown;
@@ -403,9 +403,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
403
403
  title: string;
404
404
  content: string;
405
405
  excerpt: string;
406
- slug?: string | undefined;
407
- image?: string | undefined;
408
- published?: boolean | undefined;
409
406
  tags?: ({
410
407
  name: string;
411
408
  } | {
@@ -413,6 +410,9 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
413
410
  name: string;
414
411
  slug: string;
415
412
  })[] | undefined;
413
+ slug?: string | undefined;
414
+ image?: string | undefined;
415
+ published?: boolean | undefined;
416
416
  publishedAt?: unknown;
417
417
  createdAt?: unknown;
418
418
  updatedAt?: unknown;
@@ -448,8 +448,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
448
448
  title: string;
449
449
  content: string;
450
450
  excerpt: string;
451
- image?: string | undefined;
452
- published?: boolean | undefined;
453
451
  tags?: ({
454
452
  name: string;
455
453
  } | {
@@ -457,6 +455,8 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
457
455
  name: string;
458
456
  slug: string;
459
457
  })[] | undefined;
458
+ image?: string | undefined;
459
+ published?: boolean | undefined;
460
460
  publishedAt?: unknown;
461
461
  createdAt?: unknown;
462
462
  updatedAt?: unknown;
@@ -465,8 +465,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
465
465
  title: string;
466
466
  content: string;
467
467
  excerpt: string;
468
- image?: string | undefined;
469
- published?: boolean | undefined;
470
468
  tags?: ({
471
469
  name: string;
472
470
  } | {
@@ -474,6 +472,8 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
474
472
  name: string;
475
473
  slug: string;
476
474
  })[] | undefined;
475
+ image?: string | undefined;
476
+ published?: boolean | undefined;
477
477
  publishedAt?: unknown;
478
478
  createdAt?: unknown;
479
479
  updatedAt?: unknown;
@@ -627,12 +627,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
627
627
  total: number;
628
628
  }, false>;
629
629
  readonly createPost: _btst_stack_plugins_api.Operation<zod.ZodObject<{
630
- slug: zod.ZodOptional<zod.ZodString>;
631
- title: zod.ZodString;
632
- content: zod.ZodString;
633
- excerpt: zod.ZodString;
634
- image: zod.ZodOptional<zod.ZodString>;
635
- published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
636
630
  tags: zod.ZodDefault<zod.ZodOptional<zod.ZodArray<zod.ZodUnion<readonly [zod.ZodObject<{
637
631
  name: zod.ZodString;
638
632
  }, zod_v4_core.$strip>, zod.ZodObject<{
@@ -640,6 +634,12 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
640
634
  name: zod.ZodString;
641
635
  slug: zod.ZodString;
642
636
  }, zod_v4_core.$strip>]>>>>;
637
+ slug: zod.ZodOptional<zod.ZodString>;
638
+ title: zod.ZodString;
639
+ content: zod.ZodString;
640
+ excerpt: zod.ZodString;
641
+ image: zod.ZodOptional<zod.ZodString>;
642
+ published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
643
643
  publishedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
644
644
  createdAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
645
645
  updatedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
@@ -673,12 +673,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
673
673
  readonly updatePost: _btst_stack_plugins_api.Operation<zod.ZodObject<{
674
674
  id: zod.ZodString;
675
675
  data: zod.ZodObject<{
676
- slug: zod.ZodString;
677
- title: zod.ZodString;
678
- content: zod.ZodString;
679
- excerpt: zod.ZodString;
680
- image: zod.ZodOptional<zod.ZodString>;
681
- published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
682
676
  tags: zod.ZodDefault<zod.ZodOptional<zod.ZodArray<zod.ZodUnion<readonly [zod.ZodObject<{
683
677
  name: zod.ZodString;
684
678
  }, zod_v4_core.$strip>, zod.ZodObject<{
@@ -686,6 +680,12 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
686
680
  name: zod.ZodString;
687
681
  slug: zod.ZodString;
688
682
  }, zod_v4_core.$strip>]>>>>;
683
+ slug: zod.ZodString;
684
+ title: zod.ZodString;
685
+ content: zod.ZodString;
686
+ excerpt: zod.ZodString;
687
+ image: zod.ZodOptional<zod.ZodString>;
688
+ published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
689
689
  publishedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
690
690
  createdAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
691
691
  updatedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
@@ -900,8 +900,8 @@ declare const blogResources: {
900
900
  method: "POST";
901
901
  input: (vars: PostCreateInput) => {
902
902
  body: {
903
- published: boolean;
904
903
  title: string;
904
+ published: boolean;
905
905
  content: string;
906
906
  excerpt: string;
907
907
  tags: ({
@@ -1072,8 +1072,8 @@ declare function createBlogQueryKeys(client: ReturnType<typeof createApiClient<B
1072
1072
  method: "POST";
1073
1073
  input: (vars: PostCreateInput) => {
1074
1074
  body: {
1075
- published: boolean;
1076
1075
  title: string;
1076
+ published: boolean;
1077
1077
  content: string;
1078
1078
  excerpt: string;
1079
1079
  tags: ({
@@ -2,7 +2,7 @@ import * as _tanstack_react_query from '@tanstack/react-query';
2
2
  import { z } from 'zod';
3
3
  import { ResourceFormConfig, ResourceFormResult } from '@btst/stack/plugins/client/hooks';
4
4
  import { S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.js';
5
- import { c as createPostSchema, u as updatePostSchema } from './stack.CKc_Bais.js';
5
+ import { c as createPostSchema, u as updatePostSchema } from './stack.Csi-4Mgz.js';
6
6
 
7
7
  /**
8
8
  * Options for the usePosts hook
@@ -136,10 +136,6 @@ declare function useSuspenseTags(): {
136
136
  declare function usePostForm<TValues>(config: ResourceFormConfig<TValues, SerializedPost | null, SerializedPost | null>): ResourceFormResult<TValues, SerializedPost | null, SerializedPost | null>;
137
137
  /** Create a new post */
138
138
  declare function useCreatePost(): _tanstack_react_query.UseMutationResult<SerializedPost | null, Error, {
139
- title: string;
140
- content: string;
141
- excerpt: string;
142
- published: boolean;
143
139
  tags: ({
144
140
  name: string;
145
141
  } | {
@@ -147,6 +143,10 @@ declare function useCreatePost(): _tanstack_react_query.UseMutationResult<Serial
147
143
  name: string;
148
144
  slug: string;
149
145
  })[];
146
+ title: string;
147
+ content: string;
148
+ excerpt: string;
149
+ published: boolean;
150
150
  slug?: string | undefined;
151
151
  publishedAt?: Date | undefined;
152
152
  createdAt?: Date | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -799,7 +799,37 @@
799
799
  },
800
800
  "./dist/*": "./dist/*",
801
801
  "./ui/css": "./dist/ui/components.css",
802
- "./package.json": "./package.json"
802
+ "./package.json": "./package.json",
803
+ "./plugins/blog/client/pages/posts": {
804
+ "import": {
805
+ "types": "./dist/plugins/blog/client/pages/posts.d.ts",
806
+ "default": "./dist/plugins/blog/client/pages/posts.mjs"
807
+ },
808
+ "require": {
809
+ "types": "./dist/plugins/blog/client/pages/posts.d.cts",
810
+ "default": "./dist/plugins/blog/client/pages/posts.cjs"
811
+ }
812
+ },
813
+ "./plugins/blog/client/pages/post": {
814
+ "import": {
815
+ "types": "./dist/plugins/blog/client/pages/post.d.ts",
816
+ "default": "./dist/plugins/blog/client/pages/post.mjs"
817
+ },
818
+ "require": {
819
+ "types": "./dist/plugins/blog/client/pages/post.d.cts",
820
+ "default": "./dist/plugins/blog/client/pages/post.cjs"
821
+ }
822
+ },
823
+ "./plugins/blog/client/pages/tag": {
824
+ "import": {
825
+ "types": "./dist/plugins/blog/client/pages/tag.d.ts",
826
+ "default": "./dist/plugins/blog/client/pages/tag.mjs"
827
+ },
828
+ "require": {
829
+ "types": "./dist/plugins/blog/client/pages/tag.d.cts",
830
+ "default": "./dist/plugins/blog/client/pages/tag.cjs"
831
+ }
832
+ }
803
833
  },
804
834
  "typesVersions": {
805
835
  "*": {
@@ -1024,6 +1054,15 @@
1024
1054
  ],
1025
1055
  "components/empty": [
1026
1056
  "./dist/components/empty/index.d.ts"
1057
+ ],
1058
+ "plugins/blog/client/pages/posts": [
1059
+ "./dist/plugins/blog/client/pages/posts.d.ts"
1060
+ ],
1061
+ "plugins/blog/client/pages/post": [
1062
+ "./dist/plugins/blog/client/pages/post.d.ts"
1063
+ ],
1064
+ "plugins/blog/client/pages/tag": [
1065
+ "./dist/plugins/blog/client/pages/tag.d.ts"
1027
1066
  ]
1028
1067
  }
1029
1068
  },
@@ -0,0 +1,164 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ act,
4
+ createContext,
5
+ lazy,
6
+ useContext,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useState,
10
+ type ReactNode,
11
+ type ComponentType,
12
+ } from "react";
13
+ import { createRoot, hydrateRoot } from "react-dom/client";
14
+ import { renderToString } from "react-dom/server";
15
+ import { QueryClient } from "@tanstack/react-query";
16
+ import { expect, it, vi } from "vitest";
17
+ import { z } from "zod";
18
+ import {
19
+ defineAuthorization,
20
+ definePermissions,
21
+ permission,
22
+ } from "../authorization";
23
+ import { createClientAuth } from "../authorization/client";
24
+ import { createClientStack } from "../client";
25
+ import { ComposedRoute } from "../client/components/compose";
26
+ import { StackProvider, useStack } from "../context";
27
+
28
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
29
+
30
+ const permissions = definePermissions("records", {
31
+ read: permission(z.object({ id: z.string() })),
32
+ });
33
+ const authorization = defineAuthorization({
34
+ identity: z.object({ id: z.string() }),
35
+ permissions: [permissions],
36
+ rules: ({ records }) => [
37
+ records.read.when(({ identity }) => identity?.id === "owner"),
38
+ ],
39
+ });
40
+ const auth = createClientAuth({ authorization, getIdentity: () => null });
41
+ const owner = { id: "owner" };
42
+ const access = permissions.read({ id: "one" });
43
+ const navigate = () => {};
44
+ const useRouter = () => undefinedRouter;
45
+ const undefinedRouter = {};
46
+ const onError = () => {};
47
+ const Content = () => <div data-content>Prefetched content</div>;
48
+ const Loading = () => <div data-loading>Loading</div>;
49
+ const ErrorPage = () => <div data-denied>Denied</div>;
50
+
51
+ function createStack() {
52
+ return createClientStack({
53
+ api: { baseURL: "http://test.local", basePath: "/api/data" },
54
+ site: { baseURL: "http://test.local", basePath: "/pages" },
55
+ queryClient: new QueryClient(),
56
+ plugins: {},
57
+ });
58
+ }
59
+
60
+ it.each([false, true])(
61
+ "keeps pending page hydration safe when a provider rerenders (revoke: %s)",
62
+ async (revoke) => {
63
+ const stack = createStack();
64
+ function Provider({ children }: { children: ReactNode }) {
65
+ const [updated, setUpdated] = useState(false);
66
+ useEffect(() => setUpdated(true), []);
67
+ return (
68
+ <StackProvider
69
+ stack={stack}
70
+ router={{ navigate, useRouter }}
71
+ auth={auth}
72
+ initialIdentity={updated && revoke ? null : owner}
73
+ >
74
+ <output>{updated ? "updated" : "initial"}</output>
75
+ {children}
76
+ </StackProvider>
77
+ );
78
+ }
79
+ const tree = (Page: ComponentType) => (
80
+ <Provider>
81
+ <ComposedRoute
82
+ path="/record/one"
83
+ permission={access}
84
+ PageComponent={Page}
85
+ LoadingComponent={Loading}
86
+ ErrorComponent={ErrorPage}
87
+ onError={onError}
88
+ />
89
+ </Provider>
90
+ );
91
+ const container = document.createElement("div");
92
+ container.innerHTML = renderToString(tree(Content));
93
+ expect(container.querySelector("[data-content]")).not.toBeNull();
94
+ const Pending = lazy(
95
+ () => new Promise<{ default: typeof Content }>(() => {}),
96
+ );
97
+ const consoleError = vi
98
+ .spyOn(console, "error")
99
+ .mockImplementation(() => {});
100
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
101
+ const onRecoverableError = vi.fn();
102
+ let root: ReturnType<typeof hydrateRoot> | undefined;
103
+ try {
104
+ await act(async () => {
105
+ root = hydrateRoot(container, tree(Pending), { onRecoverableError });
106
+ });
107
+ expect(container.querySelector("output")?.textContent).toBe("updated");
108
+ expect(container.querySelector("[data-content]") !== null).toBe(!revoke);
109
+ expect(container.querySelector("[data-denied]") !== null).toBe(revoke);
110
+ expect(container.querySelector("[data-loading]")).toBeNull();
111
+ expect(onRecoverableError).not.toHaveBeenCalled();
112
+ } finally {
113
+ await act(async () => root?.unmount());
114
+ stack.provider.queryClient.clear();
115
+ consoleError.mockRestore();
116
+ consoleWarn.mockRestore();
117
+ }
118
+ },
119
+ );
120
+
121
+ it("applies new router callbacks with the current host context before page effects", async () => {
122
+ const Workspace = createContext("A");
123
+ const stack = createStack();
124
+ const observations: string[] = [];
125
+ function Page() {
126
+ const workspace = useContext(Workspace);
127
+ const { router } = useStack();
128
+ useLayoutEffect(() => {
129
+ void router?.navigate?.(workspace);
130
+ }, [workspace]);
131
+ return null;
132
+ }
133
+ function App() {
134
+ const [workspace, setWorkspace] = useState("A");
135
+ useEffect(() => setWorkspace("B"), []);
136
+ return (
137
+ <Workspace.Provider value={workspace}>
138
+ <StackProvider
139
+ stack={stack}
140
+ router={{
141
+ navigate: (current) => {
142
+ observations.push(`${current}:${workspace}`);
143
+ },
144
+ }}
145
+ >
146
+ <ComposedRoute
147
+ path="/record"
148
+ PageComponent={Page}
149
+ LoadingComponent={Loading}
150
+ onError={onError}
151
+ />
152
+ </StackProvider>
153
+ </Workspace.Provider>
154
+ );
155
+ }
156
+ const root = createRoot(document.createElement("div"));
157
+ try {
158
+ await act(async () => root.render(<App />));
159
+ expect(observations).toEqual(["A:A", "B:B"]);
160
+ } finally {
161
+ await act(async () => root.unmount());
162
+ stack.provider.queryClient.clear();
163
+ }
164
+ });
@@ -193,21 +193,28 @@ export function StackAuthBoundary({
193
193
  void resolveIdentity(false);
194
194
  }, [initialIdentity, resolveIdentity]);
195
195
 
196
- return (
197
- <AuthContext.Provider
198
- value={{
199
- provider,
200
- identity: currentState.identity,
201
- isPending: currentState.isPending,
202
- sourceGeneration: currentResolutionGeneration,
203
- ...(currentState.error ? { error: currentState.error } : {}),
204
- refetch,
205
- waitForResolution,
206
- }}
207
- >
208
- {children}
209
- </AuthContext.Provider>
196
+ const value = useMemo(
197
+ () => ({
198
+ provider,
199
+ identity: currentState.identity,
200
+ isPending: currentState.isPending,
201
+ sourceGeneration: currentResolutionGeneration,
202
+ ...(currentState.error ? { error: currentState.error } : {}),
203
+ refetch,
204
+ waitForResolution,
205
+ }),
206
+ [
207
+ provider,
208
+ currentState.identity,
209
+ currentState.isPending,
210
+ currentResolutionGeneration,
211
+ currentState.error,
212
+ refetch,
213
+ waitForResolution,
214
+ ],
210
215
  );
216
+
217
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
211
218
  }
212
219
 
213
220
  /** @internal Access the raw auth context (or `null` when no provider is set). */
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  createContext,
4
4
  useContext,
5
+ useMemo,
5
6
  type ReactElement,
6
7
  type ReactNode,
7
8
  } from "react";
@@ -79,6 +80,10 @@ type StackProviderServices = {
79
80
  i18n?: StackI18nProvider;
80
81
  };
81
82
 
83
+ /** Plugin override configuration inferred from a resolved client stack. */
84
+ export type ClientStackOverrides<TStack extends ResolvedClientStack<any, any>> =
85
+ InferredPluginOverrides<RegisteredClientPlugins<TStack>>;
86
+
82
87
  type CanonicalStackProviderOverrideProps<
83
88
  TStack extends ResolvedClientStack<any, any>,
84
89
  > = InferredPluginOverrides<
@@ -110,12 +115,24 @@ function stripUndefined<T extends Record<string, any>>(obj: T): Partial<T> {
110
115
  return result as Partial<T>;
111
116
  }
112
117
 
113
- function resolveStaticRouter(
114
- router: StackRouterConfig | undefined,
115
- ): StackRouter | undefined {
116
- if (!router) return undefined;
117
- const { useRouter: _useRouter, ...staticFields } = router;
118
- return stripUndefined(staticFields);
118
+ function useStaticRouter(router: StackRouterConfig | undefined) {
119
+ const { Link, Image, navigate, refresh, getSearchParams, setSearchParams } =
120
+ router ?? {};
121
+ const present = router !== undefined;
122
+ return useMemo(
123
+ () =>
124
+ present
125
+ ? stripUndefined({
126
+ Link,
127
+ Image,
128
+ navigate,
129
+ refresh,
130
+ getSearchParams,
131
+ setSearchParams,
132
+ })
133
+ : undefined,
134
+ [present, Link, Image, navigate, refresh, getSearchParams, setSearchParams],
135
+ );
119
136
  }
120
137
 
121
138
  /**
@@ -135,15 +152,16 @@ function RouterBridge({
135
152
  children?: ReactNode;
136
153
  }) {
137
154
  const hookRouter = useRouter();
138
- const router: StackRouter = {
139
- ...staticRouter,
140
- ...stripUndefined(hookRouter),
141
- };
155
+ const context = useMemo(
156
+ () => ({
157
+ ...value,
158
+ router: { ...staticRouter, ...stripUndefined(hookRouter) },
159
+ }),
160
+ [value, staticRouter, hookRouter],
161
+ );
142
162
 
143
163
  return (
144
- <StackContext.Provider value={{ ...value, router }}>
145
- {children}
146
- </StackContext.Provider>
164
+ <StackContext.Provider value={context}>{children}</StackContext.Provider>
147
165
  );
148
166
  }
149
167
 
@@ -205,18 +223,25 @@ export function StackProvider<
205
223
  i18n,
206
224
  }: CanonicalStackProviderProps<TStack>): ReactElement {
207
225
  const projection = stack.provider;
208
- const staticRouter = resolveStaticRouter(router);
209
- const value: Omit<StackContextValue<any>, "router"> = {
210
- overrides: overrides ?? {},
211
- basePath: projection.site.basePath,
212
- api: projection.api,
213
- site: projection.site,
214
- plugins: projection.plugins,
215
- queryClient: projection.queryClient,
216
- clientStackContext: stack.context,
217
- resolvedStack: stack,
218
- auth,
219
- };
226
+ const staticRouter = useStaticRouter(router);
227
+ const value = useMemo(
228
+ () => ({
229
+ overrides: overrides ?? {},
230
+ basePath: projection.site.basePath,
231
+ api: projection.api,
232
+ site: projection.site,
233
+ plugins: projection.plugins,
234
+ queryClient: projection.queryClient,
235
+ clientStackContext: stack.context,
236
+ resolvedStack: stack,
237
+ auth,
238
+ }),
239
+ [overrides, stack, auth],
240
+ );
241
+ const context = useMemo(
242
+ () => ({ ...value, router: staticRouter }),
243
+ [value, staticRouter],
244
+ );
220
245
 
221
246
  const content = auth ? (
222
247
  <StackAuthBoundary provider={auth} initialIdentity={initialIdentity}>
@@ -235,9 +260,7 @@ export function StackProvider<
235
260
  {content}
236
261
  </RouterBridge>
237
262
  ) : (
238
- <StackContext.Provider value={{ ...value, router: staticRouter }}>
239
- {content}
240
- </StackContext.Provider>
263
+ <StackContext.Provider value={context}>{content}</StackContext.Provider>
241
264
  );
242
265
 
243
266
  return (
@@ -0,0 +1,53 @@
1
+ // @vitest-environment jsdom
2
+ import { act, lazy } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { expect, it, vi } from "vitest";
6
+ import { createClientStack } from "@btst/stack/client";
7
+ import { StackProvider } from "@btst/stack/context";
8
+ import { blogClientPlugin } from "@btst/stack/plugins/blog/client";
9
+ import { createHomePage } from "../client/components/pages/home-page";
10
+
11
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ it("reports a failed page module through the existing blog error hook and UI", async () => {
14
+ const error = new Error("Page module unavailable");
15
+ const Page = createHomePage(lazy(() => Promise.reject(error)));
16
+ const onRouteError = vi.fn();
17
+ const queryClient = new QueryClient();
18
+ const stack = createClientStack({
19
+ api: { baseURL: "http://test.local", basePath: "/api/data" },
20
+ site: { baseURL: "http://test.local", basePath: "/pages" },
21
+ queryClient,
22
+ plugins: { blog: blogClientPlugin() },
23
+ });
24
+ const container = document.createElement("div");
25
+ const root = createRoot(container);
26
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
27
+ try {
28
+ await act(async () =>
29
+ root.render(
30
+ <QueryClientProvider client={queryClient}>
31
+ <StackProvider
32
+ stack={stack}
33
+ overrides={{ blog: { onRouteError, uploadImage: async () => "" } }}
34
+ >
35
+ <Page />
36
+ </StackProvider>
37
+ </QueryClientProvider>,
38
+ ),
39
+ );
40
+ expect(
41
+ container.querySelector('[data-testid="error-placeholder"]'),
42
+ ).not.toBeNull();
43
+ expect(onRouteError).toHaveBeenCalledExactlyOnceWith("posts", error, {
44
+ path: "/blog",
45
+ isSSR: false,
46
+ published: true,
47
+ });
48
+ } finally {
49
+ await act(async () => root.unmount());
50
+ queryClient.clear();
51
+ consoleError.mockRestore();
52
+ }
53
+ });