soseki 0.0.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.
Files changed (88) hide show
  1. package/.config/_is-debug-mode.ts +9 -0
  2. package/.config/dependency-cruiser.cjs +408 -0
  3. package/.config/env.d.ts +8 -0
  4. package/.config/mise.toml +28 -0
  5. package/.config/navigation-api.d.ts +18 -0
  6. package/.config/tsconfig.build.json +28 -0
  7. package/.config/tsconfig.config.json +21 -0
  8. package/.config/tsconfig.test.json +26 -0
  9. package/.config/tsconfig.web.json +26 -0
  10. package/.config/vitest.client.ts +30 -0
  11. package/package.json +45 -0
  12. package/src/components/action-id.tsx +35 -0
  13. package/src/components/browser-router.tsx +31 -0
  14. package/src/components/hidden-input.tsx +39 -0
  15. package/src/components/outlet.tsx +17 -0
  16. package/src/components/router.tsx +234 -0
  17. package/src/contexts/route-context.ts +21 -0
  18. package/src/contexts/router-context.ts +55 -0
  19. package/src/core/_action-id-registry.ts +11 -0
  20. package/src/core/_capture-stack-trace.ts +12 -0
  21. package/src/core/_compare-route-paths.ts +90 -0
  22. package/src/core/_create-html-form-element-form-form-data.ts +32 -0
  23. package/src/core/_encode-pathname.ts +17 -0
  24. package/src/core/_is-error.ts +16 -0
  25. package/src/core/_is-promise-like.ts +14 -0
  26. package/src/core/_match-route-path.ts +39 -0
  27. package/src/core/_process-routes.ts +56 -0
  28. package/src/core/_singleton.ts +45 -0
  29. package/src/core/_unreachable.ts +22 -0
  30. package/src/core/_use-singleton.ts +24 -0
  31. package/src/core/_valibot.ts +147 -0
  32. package/src/core/_weak-id-registry.ts +125 -0
  33. package/src/core/constants.ts +4 -0
  34. package/src/core/data-map.types.ts +28 -0
  35. package/src/core/data-store.types.ts +25 -0
  36. package/src/core/deferred-promise.ts +408 -0
  37. package/src/core/errors.ts +680 -0
  38. package/src/core/expect-history-entry.ts +95 -0
  39. package/src/core/history-entry-id-schema.ts +27 -0
  40. package/src/core/history-entry-url-schema.ts +35 -0
  41. package/src/core/init-loaders.ts +79 -0
  42. package/src/core/match-routes.ts +91 -0
  43. package/src/core/readonly-form-data.types.ts +63 -0
  44. package/src/core/readonly-url.types.ts +156 -0
  45. package/src/core/redirect-response.ts +36 -0
  46. package/src/core/route-request.ts +92 -0
  47. package/src/core/route.types.ts +351 -0
  48. package/src/core/start-actions.ts +274 -0
  49. package/src/core/start-loaders.ts +254 -0
  50. package/src/core.ts +43 -0
  51. package/src/engines/engine.types.ts +216 -0
  52. package/src/engines/navigation-api-engine.ts +406 -0
  53. package/src/hooks/_use-route-context.ts +19 -0
  54. package/src/hooks/_use-router-context.ts +25 -0
  55. package/src/hooks/use-action-data.ts +37 -0
  56. package/src/hooks/use-loader-data.ts +28 -0
  57. package/src/hooks/use-navigate.ts +64 -0
  58. package/src/hooks/use-params.ts +11 -0
  59. package/src/hooks/use-pathname.ts +10 -0
  60. package/src/hooks/use-submit.ts +111 -0
  61. package/src/soseki.ts +75 -0
  62. package/src/utils/get-action-id.ts +12 -0
  63. package/src/utils/href.ts +17 -0
  64. package/src/utils/redirect.ts +13 -0
  65. package/src/utils/route-index.ts +70 -0
  66. package/src/utils/route-route.ts +111 -0
  67. package/src/utils/set-action-id.ts +14 -0
  68. package/tests/core/_capture-stack-trace.test.ts +46 -0
  69. package/tests/core/_compare-route-paths.test.ts +134 -0
  70. package/tests/core/_encode-pathname.test.ts +77 -0
  71. package/tests/core/_is-error.test.ts +108 -0
  72. package/tests/core/_is-promise-like.test.ts +100 -0
  73. package/tests/core/_match-route-path.test.ts +74 -0
  74. package/tests/core/_process-routes.test.ts +146 -0
  75. package/tests/core/_singleton.test.ts +102 -0
  76. package/tests/core/_unreachable.test.ts +38 -0
  77. package/tests/core/_use-singleton.test.ts +47 -0
  78. package/tests/core/_weak-id-registry.test.ts +137 -0
  79. package/tests/core/deferred-promise.test.ts +218 -0
  80. package/tests/core/expect-history-entry.test.ts +112 -0
  81. package/tests/core/init-loaders.test.ts +172 -0
  82. package/tests/core/match-routes.test.ts +178 -0
  83. package/tests/core/redirect-response.test.ts +36 -0
  84. package/tests/core/route-request.test.ts +93 -0
  85. package/tests/core/start-actions.test.ts +319 -0
  86. package/tests/core/start-loaders.test.ts +276 -0
  87. package/tests/engines/navigation-api-engine.test.ts +162 -0
  88. package/tsconfig.json +7 -0
@@ -0,0 +1,276 @@
1
+ import { describe, test, vi } from "vitest";
2
+ import { IDataStore } from "../../src/core/data-store.types.js";
3
+ import DeferredPromise from "../../src/core/deferred-promise.js";
4
+ import { LoaderConditionError } from "../../src/core/errors.js";
5
+ import { HistoryEntryId } from "../../src/core/history-entry-id-schema.js";
6
+ import { IAction, ILoader } from "../../src/core/route.types.js";
7
+ import startLoaders, { StartLoadersParams } from "../../src/core/start-loaders.js";
8
+
9
+ describe("ローダーの実行判定", () => {
10
+ test("前回のエントリーにデータが存在しないとき、ローダーが新規に実行される", async ({ expect }) => {
11
+ // Arrange
12
+ const loader = vi.fn().mockResolvedValue("data");
13
+ const params = createParams({
14
+ currentRoutes: [
15
+ {
16
+ path: "/",
17
+ params: {},
18
+ dataFuncs: [{
19
+ action: undefined,
20
+ loader,
21
+ shouldAction: () => expect.unreachable(),
22
+ shouldReload: () => true,
23
+ }],
24
+ },
25
+ ],
26
+ });
27
+
28
+ // Act
29
+ const wait = startLoaders(params);
30
+ await wait?.();
31
+
32
+ // Assert
33
+ expect(loader).toHaveBeenCalledOnce();
34
+ });
35
+
36
+ test("shouldReload が false を返すとき、前回のデータが引き継がれローダーは再実行されない", async ({ expect }) => {
37
+ // Arrange
38
+ const loader = vi.fn().mockResolvedValue("new data");
39
+ const prevData = Promise.resolve("old data");
40
+
41
+ const loaderDataStore = createStubDataStore();
42
+ // 前回のデータをストアにセットしておく。
43
+ const prevEntryId = "8ce9bfcd-fe55-440a-80db-e90cb6e93ac5" as HistoryEntryId;
44
+ const prevMap = new Map();
45
+ prevMap.set(loader, prevData);
46
+ loaderDataStore.set(prevEntryId, prevMap);
47
+
48
+ const params = createParams({
49
+ prevEntry: {
50
+ id: prevEntryId,
51
+ url: new URL("http://localhost/"),
52
+ },
53
+ loaderDataStore,
54
+ currentRoutes: [
55
+ {
56
+ path: "/",
57
+ params: {},
58
+ dataFuncs: [{
59
+ action: undefined,
60
+ loader,
61
+ shouldAction: () => expect.unreachable(),
62
+ shouldReload: () => false,
63
+ }],
64
+ },
65
+ ],
66
+ });
67
+
68
+ // Act
69
+ const wait = startLoaders(params);
70
+ await wait?.();
71
+
72
+ // Assert
73
+ const currentMap = loaderDataStore.get(params.currentEntry.id);
74
+ expect(loader).not.toHaveBeenCalled();
75
+ expect(await currentMap?.get(loader)).toBe("old data");
76
+ });
77
+
78
+ test("shouldReload が true を返すとき、ローダーが再実行される", async ({ expect }) => {
79
+ // Arrange
80
+ const loader: ILoader = async () => "new data";
81
+ const prevData = Promise.resolve("old data");
82
+
83
+ const loaderDataStore = createStubDataStore();
84
+ const prevEntryId = "8ce9bfcd-fe55-440a-80db-e90cb6e93ac5" as HistoryEntryId;
85
+ const prevMap = new Map();
86
+ prevMap.set(loader, prevData);
87
+ loaderDataStore.set(prevEntryId, prevMap);
88
+
89
+ const params = createParams({
90
+ prevEntry: {
91
+ id: prevEntryId,
92
+ url: new URL("http://localhost/"),
93
+ },
94
+ loaderDataStore,
95
+ currentRoutes: [
96
+ {
97
+ path: "/",
98
+ params: {},
99
+ dataFuncs: [{
100
+ action: undefined,
101
+ loader,
102
+ shouldAction: () => expect.unreachable(),
103
+ shouldReload: () => true,
104
+ }],
105
+ },
106
+ ],
107
+ });
108
+
109
+ // Act
110
+ const wait = startLoaders(params);
111
+ await wait?.();
112
+
113
+ // Assert
114
+ const currentMap = loaderDataStore.get(params.currentEntry.id);
115
+ expect(await currentMap?.get(loader)).toBe("new data");
116
+ });
117
+ });
118
+
119
+ describe("アクション実行後の挙動", () => {
120
+ test("アクション実行後に shouldReload が呼ばれるとき、triggerMethod が POST になる", async ({ expect }) => {
121
+ // Arrange
122
+ let capturedTriggerMethod: string | undefined;
123
+ const loader: ILoader = async () => "data";
124
+ const prevData = DeferredPromise.resolve("old");
125
+ const loaderDataStore = createStubDataStore();
126
+ const prevEntryId = "8ce9bfcd-fe55-440a-80db-e90cb6e93ac5" as HistoryEntryId;
127
+ loaderDataStore.set(prevEntryId, new Map([[loader, prevData]]));
128
+
129
+ const params = createParams({
130
+ prevEntry: {
131
+ id: prevEntryId,
132
+ url: new URL("http://localhost/"),
133
+ },
134
+ loaderDataStore,
135
+ formData: new FormData(),
136
+ actionResultMap: new Map(),
137
+ currentRoutes: [
138
+ {
139
+ path: "/",
140
+ params: {},
141
+ dataFuncs: [{
142
+ action: undefined,
143
+ loader,
144
+ shouldAction: () => expect.unreachable(),
145
+ shouldReload: ({ triggerMethod }) => {
146
+ capturedTriggerMethod = triggerMethod;
147
+ return true;
148
+ },
149
+ }],
150
+ },
151
+ ],
152
+ });
153
+
154
+ // Act
155
+ const wait = startLoaders(params);
156
+ await wait?.();
157
+
158
+ // Assert
159
+ expect(capturedTriggerMethod).toBe("POST");
160
+ });
161
+
162
+ test("アクションが実行された場合、前回のアクションデータが引き継がれる", ({ expect }) => {
163
+ // Arrange
164
+ const action: IAction = async () => ({ success: true });
165
+ const actionData = DeferredPromise.resolve({ success: true });
166
+ const actionDataStore = createStubDataStore();
167
+ const prevEntryId = "8ce9bfcd-fe55-440a-80db-e90cb6e93ac5" as HistoryEntryId;
168
+ actionDataStore.set(prevEntryId, new Map([[action, actionData]]));
169
+
170
+ const params = createParams({
171
+ prevEntry: {
172
+ id: prevEntryId,
173
+ url: new URL("http://localhost/"),
174
+ },
175
+ actionDataStore,
176
+ formData: new FormData(),
177
+ actionResultMap: new Map(),
178
+ currentRoutes: [
179
+ {
180
+ path: "/",
181
+ params: {},
182
+ dataFuncs: [{
183
+ action,
184
+ loader: async () => {},
185
+ shouldAction: () => expect.unreachable(),
186
+ shouldReload: () => false,
187
+ }],
188
+ },
189
+ ],
190
+ });
191
+
192
+ // Act
193
+ startLoaders(params);
194
+
195
+ // Assert
196
+ const currentActionMap = actionDataStore.get(params.currentEntry.id);
197
+ expect(currentActionMap?.get(action)).toBe(actionData);
198
+ });
199
+ });
200
+
201
+ describe("異常系", () => {
202
+ test("shouldReload が Promise を返したとき、LoaderConditionError が発生する", async ({ expect }) => {
203
+ // Arrange
204
+ const loader: ILoader = async () => "data";
205
+ const loaderDataStore = createStubDataStore();
206
+ const prevEntryId = "8ce9bfcd-fe55-440a-80db-e90cb6e93ac5" as HistoryEntryId;
207
+ loaderDataStore.set(prevEntryId, new Map([[loader, DeferredPromise.resolve("old")]]));
208
+
209
+ const params = createParams({
210
+ prevEntry: {
211
+ id: prevEntryId,
212
+ url: new URL("http://localhost/"),
213
+ },
214
+ loaderDataStore,
215
+ currentRoutes: [
216
+ {
217
+ path: "/",
218
+ params: {},
219
+ dataFuncs: [{
220
+ action: undefined,
221
+ loader,
222
+ shouldAction: () => expect.unreachable(),
223
+ // 意図的に非同期関数にする。
224
+ shouldReload: (async () => true) as any,
225
+ }],
226
+ },
227
+ ],
228
+ });
229
+
230
+ // Act
231
+ startLoaders(params);
232
+
233
+ // Assert
234
+ const currentMap = loaderDataStore.get(params.currentEntry.id);
235
+ const dataPromise = currentMap?.get(loader);
236
+ await expect(dataPromise).rejects.toThrow(LoaderConditionError);
237
+ });
238
+ });
239
+
240
+ /**
241
+ * テスト用の StartLoadersParams を作成する。
242
+ */
243
+ function createParams(overrides: Partial<StartLoadersParams> = {}): StartLoadersParams {
244
+ const params = {
245
+ prevRoutes: null,
246
+ currentRoutes: [],
247
+ prevEntry: {
248
+ id: "1e9a122d-ba56-4a57-9767-f8412ffc76f1" as HistoryEntryId,
249
+ url: new URL("http://localhost/"),
250
+ },
251
+ currentEntry: {
252
+ id: "d70c492b-7888-4a75-bf50-ec2ec758e643" as HistoryEntryId,
253
+ url: new URL("http://localhost/"),
254
+ },
255
+ actionDataStore: createStubDataStore(),
256
+ loaderDataStore: createStubDataStore(),
257
+ signal: new AbortController().signal,
258
+ ...overrides,
259
+ } as StartLoadersParams;
260
+ if (params.prevEntry.id === params.currentEntry.id) {
261
+ throw new Error("同じエントリー ID: " + params.prevEntry.id);
262
+ }
263
+
264
+ return params;
265
+ }
266
+
267
+ /**
268
+ * テスト用の IDataStore スタブを作成する。
269
+ */
270
+ function createStubDataStore(): IDataStore<any> {
271
+ const store = new Map<string, Map<any, any>>();
272
+ return {
273
+ get: (id: string) => store.get(id),
274
+ set: (id: string, data: Map<any, any>) => store.set(id, data),
275
+ } as unknown as IDataStore<any>;
276
+ }
@@ -0,0 +1,162 @@
1
+ import { afterEach, beforeEach, describe, test, vi } from "vitest";
2
+ import processRoutes from "../../src/core/_process-routes.js";
3
+ import type { IDataStore } from "../../src/core/data-store.types.js";
4
+ import type { IAction, ILoader } from "../../src/core/route.types.js";
5
+ import NavigationApiEngine from "../../src/engines/navigation-api-engine.js";
6
+
7
+ let engine: NavigationApiEngine;
8
+ let mockNavigation: any;
9
+ let mockLoaderDataStore: IDataStore<ILoader>;
10
+ let mockActionDataStore: IDataStore<IAction>;
11
+ let abortController: AbortController;
12
+
13
+ beforeEach(() => {
14
+ abortController = new AbortController();
15
+
16
+ // Navigation API のモックを作成する
17
+ mockNavigation = {
18
+ currentEntry: {
19
+ id: "59b2ba6e-5236-406c-9496-0a6bcd83b1ab",
20
+ url: "http://localhost/home",
21
+ index: 0,
22
+ addEventListener: vi.fn(),
23
+ },
24
+ entries: vi.fn().mockReturnValue([]),
25
+ addEventListener: vi.fn(),
26
+ navigate: vi.fn(),
27
+ };
28
+
29
+ // グローバルオブジェクトに Navigation API を注入する
30
+ vi.stubGlobal("navigation", mockNavigation);
31
+
32
+ mockLoaderDataStore = {
33
+ has: vi.fn(),
34
+ get: vi.fn(),
35
+ set: vi.fn(),
36
+ delete: vi.fn(),
37
+ clear: vi.fn(),
38
+ } as any;
39
+
40
+ mockActionDataStore = {
41
+ has: vi.fn(),
42
+ get: vi.fn(),
43
+ set: vi.fn(),
44
+ delete: vi.fn(),
45
+ clear: vi.fn(),
46
+ } as any;
47
+
48
+ engine = new NavigationApiEngine();
49
+ });
50
+
51
+ afterEach(() => {
52
+ vi.unstubAllGlobals();
53
+ });
54
+
55
+ describe("init", () => {
56
+ test("現在の URL にマッチするルートが存在する場合、初期状態を生成する", ({ expect }) => {
57
+ // Arrange
58
+ const routes = processRoutes([
59
+ { path: "/home" },
60
+ ]);
61
+ const args = {
62
+ routes,
63
+ getSignal: () => abortController.signal,
64
+ loaderDataStore: mockLoaderDataStore,
65
+ };
66
+
67
+ // Act
68
+ const state = engine.init(args);
69
+
70
+ // Assert
71
+ expect(state).not.toBeNull();
72
+ expect(state?.entry.id).toBe("59b2ba6e-5236-406c-9496-0a6bcd83b1ab");
73
+ expect(state?.routes).toHaveLength(1);
74
+ expect(state?.routes[0]!.path).toBe("/home");
75
+ });
76
+
77
+ test("現在の URL にマッチするルートが存在しない場合、null を返す", ({ expect }) => {
78
+ // Arrange
79
+ const routes = processRoutes([
80
+ { path: "/other" },
81
+ ]);
82
+ const args = {
83
+ routes,
84
+ getSignal: () => abortController.signal,
85
+ loaderDataStore: mockLoaderDataStore,
86
+ };
87
+
88
+ // Act
89
+ const state = engine.init(args);
90
+
91
+ // Assert
92
+ expect(state).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe("start", () => {
97
+ test("開始したとき、navigate イベントリスナーを登録する", ({ expect }) => {
98
+ // Arrange
99
+ const update = vi.fn();
100
+ const args = {
101
+ routes: [],
102
+ update,
103
+ getSignal: () => abortController.signal,
104
+ actionDataStore: mockActionDataStore,
105
+ loaderDataStore: mockLoaderDataStore,
106
+ };
107
+
108
+ // Act
109
+ engine.start(args);
110
+
111
+ // Assert
112
+ expect(mockNavigation.addEventListener).toHaveBeenCalledWith(
113
+ "navigate",
114
+ expect.any(Function),
115
+ expect.objectContaining({ signal: abortController.signal }),
116
+ );
117
+ });
118
+
119
+ test("開始したとき、既存のエントリーに対して dispose リスナーを登録する", ({ expect }) => {
120
+ // Arrange
121
+ const mockEntry = {
122
+ id: "e88788c0-49b2-437b-a6b3-69c1bdbcc6da",
123
+ addEventListener: vi.fn(),
124
+ };
125
+ mockNavigation.entries.mockReturnValue([mockEntry]);
126
+ mockLoaderDataStore.has = vi.fn().mockReturnValue(true); // データが存在する場合のみ登録される
127
+
128
+ const args = {
129
+ routes: [],
130
+ update: vi.fn(),
131
+ getSignal: () => abortController.signal,
132
+ actionDataStore: mockActionDataStore,
133
+ loaderDataStore: mockLoaderDataStore,
134
+ };
135
+
136
+ // Act
137
+ engine.start(args);
138
+
139
+ // Assert
140
+ expect(mockEntry.addEventListener).toHaveBeenCalledWith(
141
+ "dispose",
142
+ expect.any(Function),
143
+ expect.objectContaining({ signal: abortController.signal }),
144
+ );
145
+ });
146
+ });
147
+
148
+ describe("navigate", () => {
149
+ test("指定されたパスへ遷移を依頼したとき、Navigation API の navigate を呼び出す", ({ expect }) => {
150
+ // Arrange
151
+ const navigateArgs = {
152
+ to: "/dashboard",
153
+ history: "push" as const,
154
+ };
155
+
156
+ // Act
157
+ engine.navigate(navigateArgs);
158
+
159
+ // Assert
160
+ expect(mockNavigation.navigate).toHaveBeenCalledWith("/dashboard", { history: "push" });
161
+ });
162
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./.config/tsconfig.web.json" },
5
+ { "path": "./.config/tsconfig.config.json" }
6
+ ]
7
+ }