@btst/stack 3.0.1 → 3.1.0-rc.1
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.
- package/README.md +2 -2
- package/dist/context/index.d.cts +3 -1
- package/dist/context/index.d.mts +3 -1
- package/dist/context/index.d.ts +3 -1
- package/dist/packages/stack/src/context/auth.cjs +20 -14
- package/dist/packages/stack/src/context/auth.mjs +20 -14
- package/dist/packages/stack/src/context/provider.cjs +42 -22
- package/dist/packages/stack/src/context/provider.mjs +43 -23
- package/dist/packages/stack/src/plugins/blog/client/components/pages/home-page.cjs +32 -28
- package/dist/packages/stack/src/plugins/blog/client/components/pages/home-page.mjs +32 -29
- package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.cjs +53 -47
- package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.mjs +53 -48
- package/dist/packages/stack/src/plugins/blog/client/components/pages/tag-page.cjs +30 -24
- package/dist/packages/stack/src/plugins/blog/client/components/pages/tag-page.mjs +30 -25
- package/dist/plugins/blog/client/components/index.d.cts +6 -10
- package/dist/plugins/blog/client/components/index.d.mts +6 -10
- package/dist/plugins/blog/client/components/index.d.ts +6 -10
- package/dist/plugins/blog/client/pages/post.cjs +9 -0
- package/dist/plugins/blog/client/pages/post.d.cts +8 -0
- package/dist/plugins/blog/client/pages/post.d.mts +8 -0
- package/dist/plugins/blog/client/pages/post.d.ts +8 -0
- package/dist/plugins/blog/client/pages/post.mjs +7 -0
- package/dist/plugins/blog/client/pages/posts.cjs +9 -0
- package/dist/plugins/blog/client/pages/posts.d.cts +8 -0
- package/dist/plugins/blog/client/pages/posts.d.mts +8 -0
- package/dist/plugins/blog/client/pages/posts.d.ts +8 -0
- package/dist/plugins/blog/client/pages/posts.mjs +7 -0
- package/dist/plugins/blog/client/pages/tag.cjs +9 -0
- package/dist/plugins/blog/client/pages/tag.d.cts +8 -0
- package/dist/plugins/blog/client/pages/tag.d.mts +8 -0
- package/dist/plugins/blog/client/pages/tag.d.ts +8 -0
- package/dist/plugins/blog/client/pages/tag.mjs +7 -0
- package/dist/plugins/form-builder/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/form-builder/query-keys.d.cts +1 -1
- package/dist/plugins/form-builder/query-keys.d.mts +1 -1
- package/dist/plugins/form-builder/query-keys.d.ts +1 -1
- package/package.json +41 -2
- package/src/__tests__/generated-static-identity.test.tsx +128 -0
- package/src/__tests__/provider-hydration.test.tsx +164 -0
- package/src/context/auth.tsx +21 -14
- package/src/context/provider.tsx +51 -28
- package/src/plugins/blog/__tests__/page-load-error.test.tsx +53 -0
- package/src/plugins/blog/__tests__/page-loading.test.tsx +106 -0
- package/src/plugins/blog/client/components/pages/home-page.tsx +39 -35
- package/src/plugins/blog/client/components/pages/post-page.tsx +62 -54
- package/src/plugins/blog/client/components/pages/tag-page.tsx +35 -29
- package/src/plugins/blog/client/pages/post.tsx +7 -0
- package/src/plugins/blog/client/pages/posts.tsx +7 -0
- package/src/plugins/blog/client/pages/tag.tsx +7 -0
- package/src/plugins/kanban/__tests__/authorization.test.ts +54 -0
- package/dist/shared/{stack.CGmh-Lpe.d.ts → stack.BuxUQqL8.d.ts} +12 -12
- package/dist/shared/{stack.zch70Loj.d.cts → stack.CX83YNuS.d.cts} +12 -12
- package/dist/shared/{stack.Doy2_wgl.d.mts → stack.Egr3RWhA.d.mts} +12 -12
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { act } from "react";
|
|
6
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
7
|
+
import { renderToString } from "react-dom/server";
|
|
8
|
+
import { hydrateRoot } from "react-dom/client";
|
|
9
|
+
import { expect, it, vi } from "vitest";
|
|
10
|
+
import ts from "typescript";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { defineAuthorization } from "../authorization";
|
|
13
|
+
import { createClientAuth } from "../authorization/client";
|
|
14
|
+
import { createClientStack } from "../client";
|
|
15
|
+
import { StackProvider, useIdentity } from "../context";
|
|
16
|
+
import { blogPermissions } from "../plugins/blog/permissions";
|
|
17
|
+
|
|
18
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
|
|
20
|
+
// Execute the shipped templates. Only framework/application imports are injected;
|
|
21
|
+
// identity state, permission evaluation, SSR, effects and hydration are real.
|
|
22
|
+
function compile(source: string, modules: Record<string, unknown>) {
|
|
23
|
+
const { outputText } = ts.transpileModule(source, {
|
|
24
|
+
compilerOptions: {
|
|
25
|
+
module: ts.ModuleKind.CommonJS,
|
|
26
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const exports: Record<string, any> = {};
|
|
30
|
+
new Function("require", "exports", outputText)((name: string) => {
|
|
31
|
+
if (!(name in modules)) throw new Error(`Unexpected import: ${name}`);
|
|
32
|
+
return modules[name];
|
|
33
|
+
}, exports);
|
|
34
|
+
return exports;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("generated static Blog layout serves public HTML and resolves the browser session", async () => {
|
|
38
|
+
const authorization = defineAuthorization({
|
|
39
|
+
identity: z.object({ id: z.string() }),
|
|
40
|
+
permissions: [blogPermissions] as const,
|
|
41
|
+
rules: ({ blog }) => [
|
|
42
|
+
blog.post.read.when(({ facts }) => facts.scope === "published"),
|
|
43
|
+
blog.post.create.when(({ identity }) => identity !== null),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
const getIdentity = vi.fn(() => ({ id: "browser-user" }));
|
|
47
|
+
const clientAuth = createClientAuth({ authorization, getIdentity });
|
|
48
|
+
const queryClient = new QueryClient();
|
|
49
|
+
const stack = createClientStack({
|
|
50
|
+
api: { baseURL: "https://app.example", basePath: "/api/data" },
|
|
51
|
+
site: { baseURL: "https://app.example", basePath: "/pages" },
|
|
52
|
+
queryClient,
|
|
53
|
+
plugins: {},
|
|
54
|
+
});
|
|
55
|
+
const jsxRuntime = await import("react/jsx-runtime");
|
|
56
|
+
const { buildScaffoldPlan } = await import(
|
|
57
|
+
pathToFileURL(resolve(process.cwd(), "../cli/dist/lib.mjs")).href
|
|
58
|
+
);
|
|
59
|
+
const plan = await buildScaffoldPlan({
|
|
60
|
+
framework: "nextjs",
|
|
61
|
+
adapter: "memory",
|
|
62
|
+
plugins: ["blog"],
|
|
63
|
+
alias: "@/",
|
|
64
|
+
cssFile: "app/globals.css",
|
|
65
|
+
});
|
|
66
|
+
const generated = (path: string) =>
|
|
67
|
+
plan.files.find((file: { path: string }) => file.path === path)
|
|
68
|
+
.content as string;
|
|
69
|
+
const modules = {
|
|
70
|
+
react: React,
|
|
71
|
+
"react/jsx-runtime": jsxRuntime,
|
|
72
|
+
"@tanstack/react-query": { QueryClientProvider },
|
|
73
|
+
"@btst/stack/context": {
|
|
74
|
+
StackProvider: (props: React.ComponentProps<typeof StackProvider>) => (
|
|
75
|
+
<StackProvider {...props} auth={clientAuth} />
|
|
76
|
+
),
|
|
77
|
+
useIdentity,
|
|
78
|
+
},
|
|
79
|
+
"@btst/stack/next": { nextRouter: () => undefined },
|
|
80
|
+
"@/lib/query-client": { getOrCreateQueryClient: () => queryClient },
|
|
81
|
+
"@/lib/stack-client": { getStackClient: () => stack },
|
|
82
|
+
};
|
|
83
|
+
const client = compile(generated("app/pages/client-layout.tsx"), modules);
|
|
84
|
+
const { default: Layout } = compile(
|
|
85
|
+
generated("app/(static)/pages/layout.tsx"),
|
|
86
|
+
{
|
|
87
|
+
"react/jsx-runtime": jsxRuntime,
|
|
88
|
+
"../../pages/client-layout": client,
|
|
89
|
+
"@/lib/stack-client.server": { getServerClientOrigins: () => ({}) },
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
const ui = (
|
|
93
|
+
<Layout>
|
|
94
|
+
<clientAuth.CanAccess
|
|
95
|
+
permission={blogPermissions.post.read({ scope: "published" })}
|
|
96
|
+
loading={<span>Loading posts</span>}
|
|
97
|
+
>
|
|
98
|
+
<h1>Published posts</h1>
|
|
99
|
+
</clientAuth.CanAccess>
|
|
100
|
+
<clientAuth.CanAccess
|
|
101
|
+
permission={blogPermissions.post.create({ publish: "draft" })}
|
|
102
|
+
>
|
|
103
|
+
<button type="button">Write a post</button>
|
|
104
|
+
</clientAuth.CanAccess>
|
|
105
|
+
</Layout>
|
|
106
|
+
);
|
|
107
|
+
const html = renderToString(ui);
|
|
108
|
+
expect(html).toContain("<h1>Published posts</h1>");
|
|
109
|
+
expect(html).not.toContain("Loading posts");
|
|
110
|
+
expect(html).not.toContain("Write a post");
|
|
111
|
+
expect(getIdentity).not.toHaveBeenCalled();
|
|
112
|
+
const container = document.createElement("div");
|
|
113
|
+
container.innerHTML = html;
|
|
114
|
+
const onRecoverableError = vi.fn();
|
|
115
|
+
let root: ReturnType<typeof hydrateRoot> | undefined;
|
|
116
|
+
try {
|
|
117
|
+
await act(async () => {
|
|
118
|
+
root = hydrateRoot(container, ui, { onRecoverableError });
|
|
119
|
+
});
|
|
120
|
+
expect(container.querySelector("h1")?.textContent).toBe("Published posts");
|
|
121
|
+
expect(container.querySelector("button")?.textContent).toBe("Write a post");
|
|
122
|
+
expect(getIdentity).toHaveBeenCalledOnce();
|
|
123
|
+
expect(onRecoverableError).not.toHaveBeenCalled();
|
|
124
|
+
} finally {
|
|
125
|
+
await act(async () => root?.unmount());
|
|
126
|
+
queryClient.clear();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
@@ -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
|
+
});
|
package/src/context/auth.tsx
CHANGED
|
@@ -193,21 +193,28 @@ export function StackAuthBoundary({
|
|
|
193
193
|
void resolveIdentity(false);
|
|
194
194
|
}, [initialIdentity, resolveIdentity]);
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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). */
|
package/src/context/provider.tsx
CHANGED
|
@@ -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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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={{
|
|
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 =
|
|
209
|
-
const value
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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={{
|
|
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
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { StackProvider } from "@btst/stack/context";
|
|
5
|
+
import { createClientStack } from "@btst/stack/client";
|
|
6
|
+
import { blogClientPlugin } from "@btst/stack/plugins/blog/client";
|
|
7
|
+
import { PostListPage } from "@btst/stack/plugins/blog/client/pages/posts";
|
|
8
|
+
import { PostPage } from "@btst/stack/plugins/blog/client/pages/post";
|
|
9
|
+
import { TagPage } from "@btst/stack/plugins/blog/client/pages/tag";
|
|
10
|
+
import { defineAuthorization } from "@btst/stack/authorization";
|
|
11
|
+
import { createClientAuth } from "@btst/stack/authorization/client";
|
|
12
|
+
import { blogPermissions } from "@btst/stack/plugins/blog/permissions";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { BLOG_QUERY_KEYS } from "../api/query-key-defs";
|
|
15
|
+
|
|
16
|
+
const post = {
|
|
17
|
+
id: "prefetched-post",
|
|
18
|
+
slug: "prefetched-post",
|
|
19
|
+
title: "Prefetched article",
|
|
20
|
+
content: "Article available before hydration.",
|
|
21
|
+
excerpt: "Article excerpt",
|
|
22
|
+
published: true,
|
|
23
|
+
tags: [],
|
|
24
|
+
image: null,
|
|
25
|
+
authorId: null,
|
|
26
|
+
createdAt: "2026-09-16T12:00:00.000Z",
|
|
27
|
+
updatedAt: "2026-09-16T12:00:00.000Z",
|
|
28
|
+
publishedAt: "2026-09-16T12:00:00.000Z",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const authorization = defineAuthorization({
|
|
32
|
+
identity: z.object({ id: z.string() }),
|
|
33
|
+
permissions: [blogPermissions] as const,
|
|
34
|
+
rules: ({ blog }) => [
|
|
35
|
+
blog.post.read.when(
|
|
36
|
+
({ facts }) =>
|
|
37
|
+
facts.scope === "published" ||
|
|
38
|
+
(facts.scope === "post" && (!facts.exists || facts.published)),
|
|
39
|
+
),
|
|
40
|
+
blog.tag.read.allow(),
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
const auth = createClientAuth({ authorization, getIdentity: () => null });
|
|
44
|
+
|
|
45
|
+
describe("blog pages in native framework routes", () => {
|
|
46
|
+
it.each(["list", "post", "tag", "draft list", "draft post", "missing post"])(
|
|
47
|
+
"renders prefetched %s with the existing public-access rules",
|
|
48
|
+
(page) => {
|
|
49
|
+
const fetch = vi
|
|
50
|
+
.spyOn(globalThis, "fetch")
|
|
51
|
+
.mockRejectedValue(new Error("Unexpected fetch"));
|
|
52
|
+
const published = !page.startsWith("draft");
|
|
53
|
+
const queryClient = new QueryClient({
|
|
54
|
+
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
|
55
|
+
});
|
|
56
|
+
queryClient.setQueryData(
|
|
57
|
+
BLOG_QUERY_KEYS.postDetail(post.slug),
|
|
58
|
+
page === "missing post" ? null : { ...post, published },
|
|
59
|
+
);
|
|
60
|
+
queryClient.setQueryData(
|
|
61
|
+
BLOG_QUERY_KEYS.postsList({
|
|
62
|
+
published,
|
|
63
|
+
...(page === "tag" ? { tagSlug: "news" } : {}),
|
|
64
|
+
}),
|
|
65
|
+
{
|
|
66
|
+
pages: [[post]],
|
|
67
|
+
pageParams: [0],
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
queryClient.setQueryData(BLOG_QUERY_KEYS.tagsList(), [
|
|
71
|
+
{ id: "news", slug: "news", name: "News" },
|
|
72
|
+
]);
|
|
73
|
+
const stack = createClientStack({
|
|
74
|
+
api: { baseURL: "http://test.local", basePath: "/api/data" },
|
|
75
|
+
site: { baseURL: "http://test.local", basePath: "/pages" },
|
|
76
|
+
queryClient,
|
|
77
|
+
plugins: { blog: blogClientPlugin() },
|
|
78
|
+
});
|
|
79
|
+
try {
|
|
80
|
+
const html = renderToString(
|
|
81
|
+
<QueryClientProvider client={queryClient}>
|
|
82
|
+
<StackProvider stack={stack} auth={auth} initialIdentity={null}>
|
|
83
|
+
{page.endsWith("list") ? (
|
|
84
|
+
<PostListPage published={published} />
|
|
85
|
+
) : page === "tag" ? (
|
|
86
|
+
<TagPage tagSlug="news" />
|
|
87
|
+
) : (
|
|
88
|
+
<PostPage slug={post.slug} />
|
|
89
|
+
)}
|
|
90
|
+
</StackProvider>
|
|
91
|
+
</QueryClientProvider>,
|
|
92
|
+
);
|
|
93
|
+
if (published && page !== "missing post")
|
|
94
|
+
expect(html).toContain(post.title);
|
|
95
|
+
else expect(html).not.toContain(post.title);
|
|
96
|
+
if (page === "missing post") expect(html).toContain("does not exist");
|
|
97
|
+
if (published)
|
|
98
|
+
expect(html).not.toMatch(/data-testid="(?:posts|post)-skeleton"/);
|
|
99
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
100
|
+
} finally {
|
|
101
|
+
queryClient.clear();
|
|
102
|
+
fetch.mockRestore();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|