@quark-fw/plugin-react 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@quark-fw/plugin-react",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "type": "module",
7
7
  "scripts": {
8
8
  "dev": "tsx watch src/index.ts",
9
- "build": "echo raw-src package, no build"
9
+ "build": "echo raw-src package, no build",
10
+ "test": "vitest run"
10
11
  },
11
12
  "files": [
12
13
  "src"
@@ -21,8 +22,8 @@
21
22
  "access": "public"
22
23
  },
23
24
  "dependencies": {
24
- "@quark-fw/atom": "0.1.6",
25
- "@quark-fw/plugin": "0.1.6",
25
+ "@quark-fw/atom": "0.1.8",
26
+ "@quark-fw/plugin": "0.1.8",
26
27
  "@trpc/client": "^11.0.0",
27
28
  "@types/react-dom": "^19.2.3",
28
29
  "express": "^5.2.1",
@@ -31,20 +32,21 @@
31
32
  "vite-tsconfig-paths": "^6.1.1"
32
33
  },
33
34
  "peerDependencies": {
34
- "@vitejs/plugin-react": "^5.1.0",
35
- "vite": "^7.2.0",
36
35
  "@mantine/core": "^8.3.18",
37
36
  "@mantine/dates": "^8.3.18",
38
37
  "@mantine/modals": "^8.3.18",
39
38
  "@mantine/notifications": "^8.3.18",
40
- "@quark-fw/core": "0.1.6",
41
- "@quark-fw/store": "0.1.6",
39
+ "@quark-fw/core": "0.1.8",
40
+ "@quark-fw/store": "0.1.8",
41
+ "@vitejs/plugin-react": "^5.1.0",
42
42
  "react": "^19.2.4",
43
43
  "react-dom": "^19.2.4",
44
- "react-router-dom": "^7.13.2"
44
+ "react-router-dom": "^7.13.2",
45
+ "vite": "^7.1.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@types/express": "^5.0.6",
48
- "@types/react": "^19.2.14"
49
+ "@types/react": "^19.2.14",
50
+ "vitest": "^5.0.0"
49
51
  }
50
52
  }
@@ -0,0 +1,21 @@
1
+ import { memo, ReactNode, type ComponentType } from "react";
2
+
3
+ function PageComponentRenderer({
4
+ children,
5
+ Component,
6
+ ...restProps
7
+ }: {
8
+ children?: ReactNode;
9
+ Component: ComponentType<Record<string, unknown>>;
10
+ // остальное — пропсы страницы, их состав задаёт приложение
11
+ [key: string]: unknown;
12
+ }) {
13
+ return <Component {...restProps}>{children}</Component>;
14
+ }
15
+
16
+ export const MemoPageComponentRenderer = memo(
17
+ PageComponentRenderer,
18
+ (prevProps, nextProps) =>
19
+ prevProps.pathname === nextProps.pathname &&
20
+ JSON.stringify(prevProps.params) === JSON.stringify(nextProps.params),
21
+ );
@@ -1,178 +1,5 @@
1
- import {
2
- memo,
3
- ReactNode,
4
- startTransition,
5
- Suspense,
6
- useEffect,
7
- useMemo,
8
- useState,
9
- type ComponentType,
10
- } from "react";
11
- import { matchPath, useLocation } from "react-router-dom";
12
- import ErrorPage from "./ErrorPage.js";
13
- import { getRenderPath } from "./getRenderPath.js";
14
- import { ErrorHandler } from "../frontend/ErrorHandler.js";
15
- import { UglyModule, ViteModulesEager } from "./gatherPages.js";
16
- import { Loader } from "@mantine/core";
17
-
18
- export type UglyPageProps = {
19
- pathname: string;
20
- params: Record<string, string>;
21
- breadCrumbs: ReactNode | null;
22
- };
23
-
24
- export type UglyLayoutProps = UglyPageProps & {
25
- children: ReactNode;
26
- };
27
-
28
- function PageComponentRenderer({
29
- children,
30
- Component,
31
- ...restProps
32
- }: {
33
- children?: ReactNode;
34
- Component: ComponentType<Record<string, unknown>>;
35
- // остальное — пропсы страницы, их состав задаёт приложение
36
- [key: string]: unknown;
37
- }) {
38
- return <Component {...restProps}>{children}</Component>;
39
- }
40
-
41
- const MemoPageComponentRenderer = memo(
42
- PageComponentRenderer,
43
- (prevProps, nextProps) =>
44
- prevProps.pathname === nextProps.pathname &&
45
- JSON.stringify(prevProps.params) === JSON.stringify(nextProps.params),
46
- );
47
-
48
- function UglyModuleRenderer({
49
- viteModules,
50
- renderPath,
51
- depth,
52
- pathname,
53
- children,
54
- }: {
55
- renderPath: UglyModule[];
56
- depth: number;
57
- pathname: string;
58
- viteModules?: ViteModulesEager;
59
- children: ReactNode;
60
- }) {
61
- const uglyModule = renderPath[depth]!;
62
-
63
- if (!uglyModule) return children;
64
- const PageComponent = uglyModule?.getWrappedPromise()?.default;
65
-
66
- if (!PageComponent) return children;
67
-
68
- const routeProps = matchPath(
69
- {
70
- path: uglyModule!.urlPath
71
- .replace("layoutreset/", "")
72
- .replace("layout/", ""),
73
- end: false,
74
- caseSensitive: false,
75
- },
76
- pathname,
77
- );
78
-
79
- return (
80
- <ErrorHandler>
81
- <Suspense fallback={<Loader />}>
82
- <MemoPageComponentRenderer
83
- Component={PageComponent}
84
- pathname={pathname}
85
- params={routeProps?.params}
86
- >
87
- <ErrorHandler>
88
- <Suspense fallback={<Loader />}>
89
- <MemoUglyModuleRenderer
90
- pathname={pathname}
91
- viteModules={viteModules}
92
- renderPath={renderPath}
93
- depth={depth + 1}
94
- >
95
- <ErrorHandler>{children}</ErrorHandler>
96
- </MemoUglyModuleRenderer>
97
- </Suspense>
98
- </ErrorHandler>
99
- </MemoPageComponentRenderer>
100
- </Suspense>
101
- </ErrorHandler>
102
- );
103
- }
104
-
105
- const MemoUglyModuleRenderer = memo(
106
- UglyModuleRenderer,
107
- (prevProps, nextProps) => prevProps.pathname === nextProps.pathname,
108
- );
109
-
110
- function PageNotFound() {
111
- const Component = ErrorPage;
112
- return <Component code={404} />;
113
- }
114
-
115
- function UglyRouter({
116
- viteModules,
117
- registerModule,
118
- }: {
119
- viteModules?: ViteModulesEager;
120
- registerModule: (module: UglyModule) => void;
121
- }) {
122
- const { pathname } = useLocation();
123
-
124
- const [newRenderPath, newRenderPathKey] = useMemo(() => {
125
- const { path, hasPage: _hasPage } = getRenderPath(pathname);
126
- path.forEach(registerModule);
127
- return [
128
- path,
129
- `${pathname};${path.map((p) => p.urlPath).join(";")}`,
130
- _hasPage,
131
- ];
132
- // registerModule приходит пропсом и пересоздаётся на каждый рендер
133
- // родителя: с ним путь пересобирался бы вхолостую на каждый рендер
134
- // eslint-disable-next-line react-hooks/exhaustive-deps
135
- }, [pathname]);
136
-
137
- const [renderPath, setRenderPath] = useState(newRenderPath);
138
- const [currentPathname, setCurrentPathname] = useState(pathname);
139
-
140
- useEffect(() => {
141
- const loadPathModules = async () => {
142
- if (newRenderPath !== renderPath) {
143
- await Promise.all(
144
- newRenderPath.map(async (uglyModule) => uglyModule.load()),
145
- );
146
- startTransition(() => {
147
- setRenderPath(newRenderPath);
148
- setCurrentPathname(pathname);
149
- if (pathname !== currentPathname) {
150
- window.scrollTo({ top: 0 /*, behavior: 'smooth'*/ });
151
- }
152
- });
153
- }
154
- };
155
-
156
- void loadPathModules();
157
- // Эффект намеренно завязан на КЛЮЧ пути, а не на сами значения:
158
- // newRenderPath — новый массив на каждый рендер, а renderPath и
159
- // currentPathname этот же эффект и меняет. С ними он перезапускал бы
160
- // сам себя.
161
- // eslint-disable-next-line react-hooks/exhaustive-deps
162
- }, [newRenderPathKey]);
163
-
164
- return (
165
- <ErrorHandler>
166
- <MemoUglyModuleRenderer
167
- viteModules={viteModules}
168
- renderPath={renderPath}
169
- depth={0}
170
- pathname={currentPathname}
171
- >
172
- <PageNotFound />
173
- </MemoUglyModuleRenderer>
174
- </ErrorHandler>
175
- );
176
- }
177
-
178
- export { UglyRouter };
1
+ // Публичная точка входа роутера: реализация разложена по файлам
2
+ // (1 функция = 1 файл), путь @quark-fw/plugin-react/src/Router/Router
3
+ // заморожен — на него завязаны страницы приложений (UglyPageProps).
4
+ export { UglyRouter } from "./UglyRouter.js";
5
+ export type { UglyLayoutProps, UglyPageProps } from "./types.js";
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { UglyModule } from "./UglyModule.js";
3
+
4
+ const failingModule = () =>
5
+ new UglyModule({
6
+ modulePath: "/Pages/X/Page.tsx",
7
+ moduleImport: () => Promise.reject(new Error("chunk failed")),
8
+ });
9
+
10
+ describe("UglyModule", () => {
11
+ test("упавший импорт чанка не подвешивает Suspense навсегда", async () => {
12
+ const uglyModule = failingModule();
13
+
14
+ let suspender: unknown;
15
+ try {
16
+ uglyModule.getWrappedPromise();
17
+ } catch (thrown) {
18
+ suspender = thrown;
19
+ }
20
+ expect(suspender).toBeInstanceOf(Promise);
21
+
22
+ // саспендер обязан завершиться — иначе React ждёт вечно
23
+ const settled = await Promise.race([
24
+ (suspender as Promise<unknown>).then(
25
+ () => "settled",
26
+ () => "settled",
27
+ ),
28
+ new Promise((resolve) => setTimeout(() => resolve("timeout"), 100)),
29
+ ]);
30
+ expect(settled).toBe("settled");
31
+
32
+ // повторное чтение бросает саму ошибку — её покажет ErrorBoundary
33
+ expect(() => uglyModule.getWrappedPromise()).toThrow("chunk failed");
34
+ });
35
+
36
+ test("load() отклоняется вместо вечного ожидания", async () => {
37
+ const uglyModule = failingModule();
38
+
39
+ const result = await Promise.race([
40
+ uglyModule.load().then(
41
+ () => "resolved",
42
+ () => "rejected",
43
+ ),
44
+ new Promise((resolve) => setTimeout(() => resolve("timeout"), 100)),
45
+ ]);
46
+ expect(result).toBe("rejected");
47
+ });
48
+ });
@@ -0,0 +1,98 @@
1
+ import { usePromise } from "@quark-fw/store/src/usePromise.js";
2
+ import type { TModulePath, TPageModule } from "./types.js";
3
+
4
+ /*
5
+ * usePromise вызывается из класса-загрузчика, а не из компонента: чтение
6
+ * модуля происходит в теле рендера страницы, поэтому порядок вызовов совпадает
7
+ * с порядком рендеров. Ответственность за это на роутере, который читает
8
+ * страницу безусловно.
9
+ */
10
+ /* eslint-disable react-hooks/rules-of-hooks */
11
+
12
+ type TUrlPath = string;
13
+ type TFilePath = string;
14
+
15
+ const getMeta = (modulePath: TModulePath): [TFilePath, TUrlPath] => {
16
+ const filePath = modulePath.replace(/^\/Pages/, "").replace(/(\.tsx)$/, "");
17
+ let urlPath = (
18
+ filePath.endsWith("/Page")
19
+ ? filePath.replace(/Page$/, "")
20
+ : `${filePath}/`
21
+ )
22
+ .replace(/\[\.{3}.+\]/, "*")
23
+ .replaceAll(/\[(.[^[]+)\]/g, ":$1")
24
+ .toLowerCase();
25
+
26
+ urlPath = urlPath === "/main/" ? "/" : urlPath;
27
+
28
+ return [filePath, urlPath];
29
+ };
30
+
31
+ export class UglyModule {
32
+ modulePath: string;
33
+
34
+ moduleImport: () => Promise<TPageModule>;
35
+
36
+ module?: TPageModule;
37
+
38
+ loadingPromise?: Promise<TPageModule>;
39
+
40
+ wrappedPromise?: () => TPageModule;
41
+
42
+ filePath: string;
43
+
44
+ urlPath: string;
45
+
46
+ constructor({
47
+ moduleImport,
48
+ modulePath,
49
+ module,
50
+ }: {
51
+ moduleImport: () => Promise<TPageModule>;
52
+ modulePath: TModulePath;
53
+ module?: TPageModule;
54
+ }) {
55
+ this.modulePath = modulePath;
56
+ this.moduleImport = moduleImport;
57
+ this.module = module;
58
+ // this.loadingPromise;
59
+ const [filePath, urlPath] = getMeta(modulePath);
60
+ this.filePath = filePath;
61
+ this.urlPath = urlPath;
62
+ }
63
+
64
+ getWrappedPromise(force?: TPageModule) {
65
+ if (!this.module && !force) {
66
+ if (!this.wrappedPromise) {
67
+ if (!this.loadingPromise) {
68
+ // rejection НЕ глотаем: usePromise донесёт ошибку до
69
+ // ErrorBoundary — иначе Suspense висел бы вечно
70
+ this.loadingPromise = this.moduleImport();
71
+ }
72
+ this.wrappedPromise = usePromise(this.loadingPromise);
73
+ }
74
+
75
+ return this.wrappedPromise();
76
+ }
77
+ this.wrappedPromise = usePromise(
78
+ new Promise((resolve) => {
79
+ resolve(this.module! || force);
80
+ }),
81
+ );
82
+ return this.module;
83
+ }
84
+
85
+ async load() {
86
+ if (!this.module) {
87
+ if (!this.loadingPromise) {
88
+ // ошибка импорта пробрасывается вызывающему — он решает,
89
+ // логировать или показать (раньше промис висел вечно)
90
+ this.loadingPromise = this.moduleImport();
91
+ }
92
+ }
93
+
94
+ this.module = await this.loadingPromise;
95
+
96
+ return this.module;
97
+ }
98
+ }
@@ -0,0 +1,69 @@
1
+ import { memo, ReactNode, Suspense } from "react";
2
+ import { matchPath } from "react-router-dom";
3
+ import { Loader } from "@mantine/core";
4
+ import { ErrorHandler } from "../frontend/ErrorHandler.js";
5
+ import { MemoPageComponentRenderer } from "./PageComponentRenderer.js";
6
+ import { UglyModule } from "./UglyModule.js";
7
+ import type { ViteModulesEager } from "./types.js";
8
+
9
+ function UglyModuleRenderer({
10
+ viteModules,
11
+ renderPath,
12
+ depth,
13
+ pathname,
14
+ children,
15
+ }: {
16
+ renderPath: UglyModule[];
17
+ depth: number;
18
+ pathname: string;
19
+ viteModules?: ViteModulesEager;
20
+ children: ReactNode;
21
+ }) {
22
+ const uglyModule = renderPath[depth]!;
23
+
24
+ if (!uglyModule) return children;
25
+ const PageComponent = uglyModule?.getWrappedPromise()?.default;
26
+
27
+ if (!PageComponent) return children;
28
+
29
+ const routeProps = matchPath(
30
+ {
31
+ path: uglyModule!.urlPath
32
+ .replace("layoutreset/", "")
33
+ .replace("layout/", ""),
34
+ end: false,
35
+ caseSensitive: false,
36
+ },
37
+ pathname,
38
+ );
39
+
40
+ return (
41
+ <ErrorHandler>
42
+ <Suspense fallback={<Loader />}>
43
+ <MemoPageComponentRenderer
44
+ Component={PageComponent}
45
+ pathname={pathname}
46
+ params={routeProps?.params}
47
+ >
48
+ <ErrorHandler>
49
+ <Suspense fallback={<Loader />}>
50
+ <MemoUglyModuleRenderer
51
+ pathname={pathname}
52
+ viteModules={viteModules}
53
+ renderPath={renderPath}
54
+ depth={depth + 1}
55
+ >
56
+ <ErrorHandler>{children}</ErrorHandler>
57
+ </MemoUglyModuleRenderer>
58
+ </Suspense>
59
+ </ErrorHandler>
60
+ </MemoPageComponentRenderer>
61
+ </Suspense>
62
+ </ErrorHandler>
63
+ );
64
+ }
65
+
66
+ export const MemoUglyModuleRenderer = memo(
67
+ UglyModuleRenderer,
68
+ (prevProps, nextProps) => prevProps.pathname === nextProps.pathname,
69
+ );
@@ -0,0 +1,77 @@
1
+ import { startTransition, useEffect, useMemo, useState } from "react";
2
+ import { useLocation } from "react-router-dom";
3
+ import ErrorPage from "./ErrorPage.js";
4
+ import { getRenderPath } from "./getRenderPath.js";
5
+ import { ErrorHandler } from "../frontend/ErrorHandler.js";
6
+ import { MemoUglyModuleRenderer } from "./UglyModuleRenderer.js";
7
+ import { UglyModule } from "./UglyModule.js";
8
+ import type { ViteModulesEager } from "./types.js";
9
+
10
+ function PageNotFound() {
11
+ const Component = ErrorPage;
12
+ return <Component code={404} />;
13
+ }
14
+
15
+ export function UglyRouter({
16
+ viteModules,
17
+ registerModule,
18
+ }: {
19
+ viteModules?: ViteModulesEager;
20
+ registerModule: (module: UglyModule) => void;
21
+ }) {
22
+ const { pathname } = useLocation();
23
+
24
+ const [newRenderPath, newRenderPathKey] = useMemo(() => {
25
+ const { path, hasPage: _hasPage } = getRenderPath(pathname);
26
+ path.forEach(registerModule);
27
+ return [
28
+ path,
29
+ `${pathname};${path.map((p) => p.urlPath).join(";")}`,
30
+ _hasPage,
31
+ ];
32
+ // registerModule приходит пропсом и пересоздаётся на каждый рендер
33
+ // родителя: с ним путь пересобирался бы вхолостую на каждый рендер
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [pathname]);
36
+
37
+ const [renderPath, setRenderPath] = useState(newRenderPath);
38
+ const [currentPathname, setCurrentPathname] = useState(pathname);
39
+
40
+ useEffect(() => {
41
+ const loadPathModules = async () => {
42
+ if (newRenderPath !== renderPath) {
43
+ await Promise.all(
44
+ newRenderPath.map(async (uglyModule) => uglyModule.load()),
45
+ );
46
+ startTransition(() => {
47
+ setRenderPath(newRenderPath);
48
+ setCurrentPathname(pathname);
49
+ if (pathname !== currentPathname) {
50
+ window.scrollTo({ top: 0 /*, behavior: 'smooth'*/ });
51
+ }
52
+ });
53
+ }
54
+ };
55
+
56
+ // упавший чанк: остаёмся на текущей странице, ошибку — в консоль
57
+ loadPathModules().catch(console.error);
58
+ // Эффект намеренно завязан на КЛЮЧ пути, а не на сами значения:
59
+ // newRenderPath — новый массив на каждый рендер, а renderPath и
60
+ // currentPathname этот же эффект и меняет. С ними он перезапускал бы
61
+ // сам себя.
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, [newRenderPathKey]);
64
+
65
+ return (
66
+ <ErrorHandler>
67
+ <MemoUglyModuleRenderer
68
+ viteModules={viteModules}
69
+ renderPath={renderPath}
70
+ depth={0}
71
+ pathname={currentPathname}
72
+ >
73
+ <PageNotFound />
74
+ </MemoUglyModuleRenderer>
75
+ </ErrorHandler>
76
+ );
77
+ }
@@ -0,0 +1,32 @@
1
+ import { UglyModule } from "./UglyModule.js";
2
+ import type {
3
+ TModulePath,
4
+ TPageModule,
5
+ ViteModules,
6
+ ViteModulesEager,
7
+ } from "./types.js";
8
+
9
+ export const createUglyModules = (modules: ViteModules | ViteModulesEager) =>
10
+ Object.keys(modules).reduce(
11
+ (acc, modulePath) => {
12
+ const moduleImport = modules[modulePath];
13
+ if (typeof moduleImport === "object") {
14
+ acc[modulePath] = new UglyModule({
15
+ modulePath,
16
+ module: moduleImport,
17
+ moduleImport: () =>
18
+ new Promise<TPageModule>((resolve) =>
19
+ resolve(moduleImport),
20
+ ),
21
+ });
22
+ } else {
23
+ acc[modulePath] = new UglyModule({
24
+ modulePath,
25
+ moduleImport: moduleImport!,
26
+ });
27
+ }
28
+
29
+ return acc;
30
+ },
31
+ {} as Record<TModulePath, UglyModule>,
32
+ );