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,108 @@
1
+ import { describe, test } from "vitest";
2
+ import isError from "../../src/core/_is-error.js";
3
+
4
+ describe("Error オブジェクトの判定", () => {
5
+ test("Error インスタンスを渡したとき、true になる", ({ expect }) => {
6
+ // Arrange
7
+ const input = new Error("test error");
8
+
9
+ // Act
10
+ const result = isError(input);
11
+
12
+ // Assert
13
+ expect(result).toBe(true);
14
+ });
15
+
16
+ test("Error を継承したクラスのインスタンスを渡したとき、true になる", ({ expect }) => {
17
+ // Arrange
18
+ class CustomError extends Error {}
19
+ const input = new CustomError("custom error");
20
+
21
+ // Act
22
+ const result = isError(input);
23
+
24
+ // Assert
25
+ expect(result).toBe(true);
26
+ });
27
+ });
28
+
29
+ // describe("Error に類似したオブジェクトの判定", () => {
30
+ // test("name と message を持つプレーンなオブジェクトを渡したとき、true になる", ({ expect }) => {
31
+ // // Arrange
32
+ // const input = { name: "CustomError", message: "something went wrong" };
33
+
34
+ // // Act
35
+ // const result = isError(input);
36
+
37
+ // // Assert
38
+ // expect(result).toBe(true);
39
+ // });
40
+ // });
41
+
42
+ describe("Error ではない値の判定", () => {
43
+ test("null を渡したとき、false になる", ({ expect }) => {
44
+ // Arrange
45
+ const input = null;
46
+
47
+ // Act
48
+ const result = isError(input);
49
+
50
+ // Assert
51
+ expect(result).toBe(false);
52
+ });
53
+
54
+ test("undefined を渡したとき、false になる", ({ expect }) => {
55
+ // Arrange
56
+ const input = undefined;
57
+
58
+ // Act
59
+ const result = isError(input);
60
+
61
+ // Assert
62
+ expect(result).toBe(false);
63
+ });
64
+
65
+ test("文字列を渡したとき、false になる", ({ expect }) => {
66
+ // Arrange
67
+ const input = "some error message";
68
+
69
+ // Act
70
+ const result = isError(input);
71
+
72
+ // Assert
73
+ expect(result).toBe(false);
74
+ });
75
+
76
+ test("name のみが定義されたオブジェクトを渡したとき、false になる", ({ expect }) => {
77
+ // Arrange
78
+ const input = { name: "Error" };
79
+
80
+ // Act
81
+ const result = isError(input);
82
+
83
+ // Assert
84
+ expect(result).toBe(false);
85
+ });
86
+
87
+ test("message のみが定義されたオブジェクトを渡したとき、false になる", ({ expect }) => {
88
+ // Arrange
89
+ const input = { message: "error occurred" };
90
+
91
+ // Act
92
+ const result = isError(input);
93
+
94
+ // Assert
95
+ expect(result).toBe(false);
96
+ });
97
+
98
+ test("name が string ではないオブジェクトを渡したとき、false になる", ({ expect }) => {
99
+ // Arrange
100
+ const input = { name: 123, message: "error" };
101
+
102
+ // Act
103
+ const result = isError(input);
104
+
105
+ // Assert
106
+ expect(result).toBe(false);
107
+ });
108
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, test } from "vitest";
2
+ import isPromiseLike from "../../src/core/_is-promise-like.js";
3
+
4
+ describe("値が PromiseLike である場合", () => {
5
+ test("Promise インスタンスを渡したとき、 true を返す", ({ expect }) => {
6
+ // Arrange
7
+ const value = Promise.resolve("test");
8
+
9
+ // Act
10
+ const result = isPromiseLike(value);
11
+
12
+ // Assert
13
+ expect(result).toBe(true);
14
+ });
15
+
16
+ test("then メソッドを持つオブジェクトを渡したとき、 true を返す", ({ expect }) => {
17
+ // Arrange
18
+ const value = {
19
+ then: () => {},
20
+ };
21
+
22
+ // Act
23
+ const result = isPromiseLike(value);
24
+
25
+ // Assert
26
+ expect(result).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe("値が PromiseLike ではない場合", () => {
31
+ test("null を渡したとき、 false を返す", ({ expect }) => {
32
+ // Arrange
33
+ const value = null;
34
+
35
+ // Act
36
+ const result = isPromiseLike(value);
37
+
38
+ // Assert
39
+ expect(result).toBe(false);
40
+ });
41
+
42
+ test("undefined を渡したとき、 false を返す", ({ expect }) => {
43
+ // Arrange
44
+ const value = undefined;
45
+
46
+ // Act
47
+ const result = isPromiseLike(value);
48
+
49
+ // Assert
50
+ expect(result).toBe(false);
51
+ });
52
+
53
+ test("then プロパティを持たないオブジェクトを渡したとき、 false を返す", ({ expect }) => {
54
+ // Arrange
55
+ const value = {
56
+ other: "property",
57
+ };
58
+
59
+ // Act
60
+ const result = isPromiseLike(value);
61
+
62
+ // Assert
63
+ expect(result).toBe(false);
64
+ });
65
+
66
+ test("then プロパティが関数ではないオブジェクトを渡したとき、 false を返す", ({ expect }) => {
67
+ // Arrange
68
+ const value = {
69
+ then: "not a function",
70
+ };
71
+
72
+ // Act
73
+ const result = isPromiseLike(value);
74
+
75
+ // Assert
76
+ expect(result).toBe(false);
77
+ });
78
+
79
+ test("プリミティブな数値を渡したとき、 false を返す", ({ expect }) => {
80
+ // Arrange
81
+ const value = 123;
82
+
83
+ // Act
84
+ const result = isPromiseLike(value);
85
+
86
+ // Assert
87
+ expect(result).toBe(false);
88
+ });
89
+
90
+ test("プリミティブな文字列を渡したとき、 false を返す", ({ expect }) => {
91
+ // Arrange
92
+ const value = "string";
93
+
94
+ // Act
95
+ const result = isPromiseLike(value);
96
+
97
+ // Assert
98
+ expect(result).toBe(false);
99
+ });
100
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, test } from "vitest";
2
+ import matchPath from "../../src/core/_match-route-path.js";
3
+
4
+ describe("パス名がルートのパターンに合致する場合", () => {
5
+ test("パスから動的パラメーターが抽出され、MatchPathResult オブジェクトが返される", ({ expect }) => {
6
+ // Arrange
7
+ const route = {
8
+ // path: "/users/:id",
9
+ pathPattern: /^\/users\/([^/]+?)(?:\/)?$/i,
10
+ paramKeys: ["id"],
11
+ };
12
+ const pathname = "/users/123";
13
+
14
+ // Act
15
+ const result = matchPath(route, pathname);
16
+
17
+ // Assert
18
+ expect(result).not.toBeNull();
19
+ expect(result?.params).toStrictEqual({ id: "123" });
20
+ });
21
+
22
+ test("複数の動的パラメーターが含まれるとき、すべてのパラメーターが正しく抽出される", ({ expect }) => {
23
+ // Arrange
24
+ const route = {
25
+ // path: "/posts/:year/:month",
26
+ pathPattern: /^\/posts\/([^/]+?)\/([^/]+?)(?:\/)?$/i,
27
+ paramKeys: ["year", "month"],
28
+ };
29
+ const pathname = "/posts/2025/12";
30
+
31
+ // Act
32
+ const result = matchPath(route, pathname);
33
+
34
+ // Assert
35
+ expect(result?.params).toStrictEqual({
36
+ year: "2025",
37
+ month: "12",
38
+ });
39
+ });
40
+
41
+ test("パスに動的パラメーターが含まれないとき、空の params を持つオブジェクトが返される", ({ expect }) => {
42
+ // Arrange
43
+ const route = {
44
+ // path: "/about",
45
+ pathPattern: /^\/about(?:\/)?$/i,
46
+ paramKeys: [],
47
+ };
48
+ const pathname = "/about";
49
+
50
+ // Act
51
+ const result = matchPath(route, pathname);
52
+
53
+ // Assert
54
+ expect(result?.params).toStrictEqual({});
55
+ });
56
+ });
57
+
58
+ describe("パス名がルートのパターンに合致しない場合", () => {
59
+ test("マッチングに失敗したとき、null が返される", ({ expect }) => {
60
+ // Arrange
61
+ const route = {
62
+ // path: "/users/:id",
63
+ pathPattern: /^\/users\/([^/]+?)(?:\/)?$/i,
64
+ paramKeys: ["id"],
65
+ };
66
+ const pathname = "/other/123";
67
+
68
+ // Act
69
+ const result = matchPath(route, pathname);
70
+
71
+ // Assert
72
+ expect(result).toBeNull();
73
+ });
74
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, test } from "vitest";
2
+ import preprocessRoutes from "../../src/core/_process-routes.js";
3
+ import type { RouteDefinition } from "../../src/core/route.types.js";
4
+
5
+ describe("パスの正規化とマッチング", () => {
6
+ test("階層構造を持つルート定義を渡したとき、親のパスを引き継いで正規化される", ({ expect }) => {
7
+ // Arrange
8
+ const routes: RouteDefinition[] = [
9
+ {
10
+ path: "parent",
11
+ children: [
12
+ { path: "child" },
13
+ ],
14
+ },
15
+ ];
16
+
17
+ // Act
18
+ const result = preprocessRoutes(routes);
19
+
20
+ // Assert
21
+ expect(result[0]?.path).toBe("/parent");
22
+ expect(result[0]?.children[0]?.path).toBe("/parent/child");
23
+ });
24
+
25
+ test("ルート定義が入れ子になっているとき、パスパターンの正規表現が適切に生成される", ({ expect }) => {
26
+ // Arrange
27
+ const routes: RouteDefinition[] = [
28
+ {
29
+ path: "/users",
30
+ children: [
31
+ { path: ":id" },
32
+ ],
33
+ },
34
+ ];
35
+
36
+ // Act
37
+ const result = preprocessRoutes(routes);
38
+ const childRoute = result[0]?.children[0];
39
+
40
+ // Assert
41
+ expect(childRoute?.pathPattern).toBeInstanceOf(RegExp);
42
+ expect(childRoute?.pathPattern.test("/users/123")).toBe(true);
43
+ expect(childRoute?.paramKeys).toStrictEqual(["id"]);
44
+ });
45
+
46
+ test("複数のルートが存在するとき、パスの文字列順でソートされる", ({ expect }) => {
47
+ // Arrange
48
+ const routes: RouteDefinition[] = [
49
+ { path: "/zebra" },
50
+ { path: "/apple" },
51
+ ];
52
+
53
+ // Act
54
+ const result = preprocessRoutes(routes);
55
+
56
+ // Assert
57
+ expect(result[0]?.path).toBe("/apple");
58
+ expect(result[1]?.path).toBe("/zebra");
59
+ });
60
+ });
61
+
62
+ describe("データ操作関数の補完", () => {
63
+ test("dataFunctions が未定義の場合、空の配列として処理される", ({ expect }) => {
64
+ // Arrange
65
+ const routes: RouteDefinition[] = [
66
+ { path: "/" },
67
+ ];
68
+
69
+ // Act
70
+ const result = preprocessRoutes(routes);
71
+
72
+ // Assert
73
+ expect(result[0]?.dataFuncs).toStrictEqual([]);
74
+ });
75
+
76
+ test("shouldAction または shouldReload が未定義のとき、常にデフォルト判定を返す関数が設定される", ({ expect }) => {
77
+ // Arrange
78
+ const routes: RouteDefinition[] = [
79
+ {
80
+ path: "/",
81
+ dataFunctions: [
82
+ {
83
+ loader: async () => ({}),
84
+ // shouldReload と shouldAction は省略
85
+ },
86
+ ],
87
+ },
88
+ ];
89
+
90
+ // Act
91
+ const result = preprocessRoutes(routes);
92
+ const dataFunc = result[0]?.dataFuncs[0];
93
+
94
+ // Assert
95
+ expect(dataFunc?.shouldAction({ defaultShouldAction: true } as any)).toBe(true);
96
+ expect(dataFunc?.shouldReload({ defaultShouldReload: false } as any)).toBe(false);
97
+ });
98
+
99
+ test("定義された action や loader がそのまま保持される", ({ expect }) => {
100
+ // Arrange
101
+ const mockLoader = async () => ({ message: "hello" });
102
+ const routes: RouteDefinition[] = [
103
+ {
104
+ path: "/",
105
+ dataFunctions: [{ loader: mockLoader }],
106
+ },
107
+ ];
108
+
109
+ // Act
110
+ const result = preprocessRoutes(routes);
111
+
112
+ // Assert
113
+ expect(result[0]?.dataFuncs[0]?.loader).toBe(mockLoader);
114
+ });
115
+ });
116
+
117
+ describe("ルートの属性判定", () => {
118
+ test("子ルートを持たないルートは、index 属性が true になる", ({ expect }) => {
119
+ // Arrange
120
+ const routes: RouteDefinition[] = [
121
+ { path: "/leaf" },
122
+ ];
123
+
124
+ // Act
125
+ const result = preprocessRoutes(routes);
126
+
127
+ // Assert
128
+ expect(result[0]?.index).toBe(true);
129
+ });
130
+
131
+ test("子ルートを持つルートは、index 属性が false になる", ({ expect }) => {
132
+ // Arrange
133
+ const routes: RouteDefinition[] = [
134
+ {
135
+ path: "/parent",
136
+ children: [{ path: "child" }],
137
+ },
138
+ ];
139
+
140
+ // Act
141
+ const result = preprocessRoutes(routes);
142
+
143
+ // Assert
144
+ expect(result[0]?.index).toBe(false);
145
+ });
146
+ });
@@ -0,0 +1,102 @@
1
+ import { afterEach, describe, test } from "vitest";
2
+ import singleton from "../../src/core/_singleton.js";
3
+
4
+ afterEach(() => {
5
+ // テスト後にキャッシュをクリーンアップする。
6
+ globalThis.soseki__singleton = undefined;
7
+ });
8
+
9
+ describe("同期関数の場合", () => {
10
+ test("初回呼び出しのとき、ファクトリー関数の戻り値を返す", ({ expect }) => {
11
+ // Arrange
12
+ const key = "test-key";
13
+ const expectedValue = { id: 1 };
14
+
15
+ // Act
16
+ const result = singleton(key, () => expectedValue);
17
+
18
+ // Assert
19
+ expect(result).toBe(expectedValue);
20
+ });
21
+
22
+ test("同じキーで複数回呼び出したとき、常に最初に生成されたインスタンスを返す", ({ expect }) => {
23
+ // Arrange
24
+ const key = "stable-key";
25
+ let callCount = 0;
26
+ const factory = () => {
27
+ callCount++;
28
+ return { count: callCount };
29
+ };
30
+
31
+ // Act
32
+ const firstResult = singleton(key, factory);
33
+ const secondResult = singleton(key, factory);
34
+
35
+ // Assert
36
+ expect(secondResult).toBe(firstResult);
37
+ expect(callCount).toBe(1);
38
+ });
39
+
40
+ test("異なるキーで呼び出したとき、それぞれ個別のインスタンスを返す", ({ expect }) => {
41
+ // Arrange
42
+ const keyA = "key-a";
43
+ const keyB = "key-b";
44
+
45
+ // Act
46
+ const resultA = singleton(keyA, () => ({ name: "A" }));
47
+ const resultB = singleton(keyB, () => ({ name: "B" }));
48
+
49
+ // Assert
50
+ expect(resultA).not.toBe(resultB);
51
+ });
52
+ });
53
+
54
+ describe("非同期関数の場合", () => {
55
+ test("初回呼び出しのとき、解決された値を返す Promise を返す", async ({ expect }) => {
56
+ // Arrange
57
+ const key = "async-key";
58
+ const expectedValue = "resolved-value";
59
+
60
+ // Act
61
+ const resultPromise = singleton(key, async () => expectedValue);
62
+ const result = await resultPromise;
63
+
64
+ // Assert
65
+ expect(result).toBe(expectedValue);
66
+ });
67
+
68
+ test("解決待ちの間に再度呼び出されたとき、同一の Promise インスタンスを返す", ({ expect }) => {
69
+ // Arrange
70
+ const key = "pending-key";
71
+ const factory = async () => "value";
72
+
73
+ // Act
74
+ const firstPromise = singleton(key, factory);
75
+ const secondPromise = singleton(key, factory);
76
+
77
+ // Assert
78
+ expect(firstPromise).toBe(secondPromise);
79
+ });
80
+
81
+ test("Promise が拒絶されたとき、次に呼び出された際にファクトリー関数を再実行する", async ({ expect }) => {
82
+ // Arrange
83
+ const key = "error-key";
84
+ let callCount = 0;
85
+ const factory = async () => {
86
+ callCount++;
87
+ if (callCount === 1) {
88
+ throw new Error("初回失敗");
89
+ }
90
+ return "成功";
91
+ };
92
+
93
+ // Act & Assert
94
+ // 1 回目の呼び出し:エラーになることを確認する。
95
+ await expect(singleton(key, factory)).rejects.toThrow("初回失敗");
96
+
97
+ // 2 回目の呼び出し:キャッシュが削除されており、再試行されることを確認する。
98
+ const secondResult = await singleton(key, factory);
99
+ expect(secondResult).toBe("成功");
100
+ expect(callCount).toBe(2);
101
+ });
102
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, test } from "vitest";
2
+ import unreachable from "../../src/core/_unreachable.js";
3
+ import { UnreachableError } from "../../src/core/errors.js";
4
+
5
+ describe("引数なしで呼び出した場合", () => {
6
+ test("UnreachableError がスローされる", ({ expect }) => {
7
+ // Arrange
8
+ const act = () => {
9
+ unreachable();
10
+ };
11
+
12
+ // Act & Assert
13
+ expect(act).toThrow(UnreachableError);
14
+ });
15
+ });
16
+
17
+ describe("switch 文の網羅性チェックなどで予期しない値が渡された場合", () => {
18
+ test("渡された値を含む UnreachableError がスローされる", ({ expect }) => {
19
+ // Arrange
20
+ type Shape = "circle" | "square";
21
+ const shape = "triangle" as unknown as Shape;
22
+
23
+ // Act
24
+ const act = () => {
25
+ switch (shape) {
26
+ case "circle":
27
+ return 1;
28
+ case "square":
29
+ return 2;
30
+ default:
31
+ return unreachable(shape);
32
+ }
33
+ };
34
+
35
+ // Assert
36
+ expect(act).toThrow(UnreachableError);
37
+ });
38
+ });
@@ -0,0 +1,47 @@
1
+ import { test, vi } from "vitest";
2
+ import { renderHook } from "vitest-browser-react";
3
+ import useSingleton from "../../src/core/_use-singleton.js";
4
+
5
+ test("最初のレンダリング時に、初期化関数が実行されてその結果を返す", async ({ expect }) => {
6
+ // Arrange
7
+ const expectedValue = "initial value";
8
+ const initFn = () => expectedValue;
9
+
10
+ // Act
11
+ const { result } = await renderHook(() => useSingleton(initFn));
12
+
13
+ // Assert
14
+ expect(result.current).toBe(expectedValue);
15
+ });
16
+
17
+ test("再レンダリングが発生しても、初期化関数は二度目以降の実行はされない", async ({ expect }) => {
18
+ // Arrange
19
+ const initFn = vi.fn(() => ({ id: Math.random() }));
20
+
21
+ // Act
22
+ const { rerender } = await renderHook(() => useSingleton(initFn));
23
+
24
+ // 再レンダリングを実行する。
25
+ await rerender();
26
+ await rerender();
27
+
28
+ // Assert
29
+ expect(initFn).toHaveBeenCalledTimes(1);
30
+ });
31
+
32
+ test("再レンダリングが発生しても、最初に生成されたオブジェクトと同一のインスタンスを返し続ける", async ({ expect }) => {
33
+ // Arrange
34
+ // 毎回新しいオブジェクトを生成する初期化関数を準備
35
+ const initFn = () => ({ timestamp: Date.now() });
36
+
37
+ // Act
38
+ const { result, rerender } = await renderHook(() => useSingleton(initFn));
39
+ const firstResult = result.current;
40
+
41
+ await rerender();
42
+ const secondResult = result.current;
43
+
44
+ // Assert
45
+ // 参照が同一であることを確認し、シングルトンとしての振る舞いを保証
46
+ expect(secondResult).toBe(firstResult);
47
+ });