@gasboost/react 0.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.
@@ -0,0 +1,2 @@
1
+ import type { PropsWithChildren } from "react";
2
+ export declare function AppsScriptRouter({ children }: PropsWithChildren): import("react").ReactNode;
@@ -0,0 +1,19 @@
1
+ import { AppsScriptHistoryPipeline } from "@gasboost/client";
2
+ import { useEffect, useState } from "react";
3
+ export function AppsScriptRouter({ children }) {
4
+ const [ready, setReady] = useState(false);
5
+ useEffect(() => {
6
+ let dispose;
7
+ AppsScriptHistoryPipeline.create((pipeline) => {
8
+ dispose = pipeline.sync();
9
+ setReady(true);
10
+ });
11
+ return () => {
12
+ dispose?.();
13
+ };
14
+ }, []);
15
+ if (!ready) {
16
+ return null;
17
+ }
18
+ return children;
19
+ }
@@ -0,0 +1,2 @@
1
+ export { AppsScriptRouter } from "./AppsScriptRouter";
2
+ export { useAppsScriptJob } from "./useAppsScriptJob";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { AppsScriptRouter } from "./AppsScriptRouter";
2
+ export { useAppsScriptJob } from "./useAppsScriptJob";
@@ -0,0 +1,2 @@
1
+ import { AppsScriptJobStore } from "@gasboost/client";
2
+ export declare function useAppsScriptJob(jobStore: AppsScriptJobStore): import("@gasboost/client").AppsScriptJob<any>[];
@@ -0,0 +1,4 @@
1
+ import { useSyncExternalStore } from "react";
2
+ export function useAppsScriptJob(jobStore) {
3
+ return useSyncExternalStore(jobStore.subscribe.bind(jobStore), jobStore.getSnapshot.bind(jobStore));
4
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@gasboost/react",
3
+ "version": "0.1.0",
4
+ "description": "React integration for Google Apps Script web applications.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/gasboost/app.git",
8
+ "directory": "packages/react"
9
+ },
10
+ "license": "MIT",
11
+ "author": "tiger-oshima",
12
+ "keywords": [
13
+ "google-apps-script",
14
+ "gas",
15
+ "react",
16
+ "router",
17
+ "web-app",
18
+ "gasboost"
19
+ ],
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "@gasboost/client": "0.1.0"
30
+ },
31
+ "peerDependencies": {
32
+ "react": "^19"
33
+ },
34
+ "devDependencies": {
35
+ "@testing-library/react": "^16.3.3",
36
+ "@types/react": "^19.2.18"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit -p tsconfig.json",
40
+ "test": "vitest run --config vitest.config.mts",
41
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
42
+ }
43
+ }
@@ -0,0 +1,26 @@
1
+ import { AppsScriptHistoryPipeline } from "@gasboost/client";
2
+ import type { PropsWithChildren } from "react";
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function AppsScriptRouter({ children }: PropsWithChildren) {
6
+ const [ready, setReady] = useState(false);
7
+
8
+ useEffect(() => {
9
+ let dispose: (() => void) | undefined;
10
+
11
+ AppsScriptHistoryPipeline.create((pipeline) => {
12
+ dispose = pipeline.sync();
13
+ setReady(true);
14
+ });
15
+
16
+ return () => {
17
+ dispose?.();
18
+ };
19
+ }, []);
20
+
21
+ if (!ready) {
22
+ return null;
23
+ }
24
+
25
+ return children;
26
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { AppsScriptRouter } from "./AppsScriptRouter";
2
+ export { useAppsScriptJob } from "./useAppsScriptJob";
@@ -0,0 +1,9 @@
1
+ import { AppsScriptJobStore } from "@gasboost/client";
2
+ import { useSyncExternalStore } from "react";
3
+
4
+ export function useAppsScriptJob(jobStore: AppsScriptJobStore) {
5
+ return useSyncExternalStore(
6
+ jobStore.subscribe.bind(jobStore),
7
+ jobStore.getSnapshot.bind(jobStore),
8
+ );
9
+ }
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { AppsScriptHistoryPipeline } from "@gasboost/client";
5
+ import { AppsScriptRouter } from "../src/AppsScriptRouter";
6
+ vi.mock("@gasboost/client", () => ({
7
+ AppsScriptHistoryPipeline: {
8
+ create: vi.fn(),
9
+ },
10
+ }));
11
+ describe("AppsScriptRouter", () => {
12
+ beforeEach(() => {
13
+ vi.resetAllMocks();
14
+ cleanup();
15
+ });
16
+ it("pipeline の生成が完了するまでは children を描画しない", () => {
17
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation(() => { });
18
+ render(_jsx(AppsScriptRouter, { children: _jsx("div", { children: "app" }) }));
19
+ expect(screen.queryByText("app")).toBeNull();
20
+ });
21
+ it("pipeline 生成後に同期を開始する", async () => {
22
+ const sync = vi.fn(() => vi.fn());
23
+ const pipeline = {
24
+ sync,
25
+ };
26
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation((callback) => {
27
+ callback(pipeline);
28
+ });
29
+ render(_jsx(AppsScriptRouter, { children: _jsx("div", { children: "app" }) }));
30
+ await waitFor(() => {
31
+ expect(sync).toHaveBeenCalledOnce();
32
+ });
33
+ expect(AppsScriptHistoryPipeline.create).toHaveBeenCalledOnce();
34
+ });
35
+ it("同期開始後に children を描画する", async () => {
36
+ const pipeline = {
37
+ sync: vi.fn(() => vi.fn()),
38
+ };
39
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation((callback) => {
40
+ callback(pipeline);
41
+ });
42
+ render(_jsx(AppsScriptRouter, { children: _jsx("div", { children: "app" }) }));
43
+ expect(await screen.findByText("app")).toBeTruthy();
44
+ });
45
+ it("アンマウント時に pipeline の監視を解除する", async () => {
46
+ const dispose = vi.fn();
47
+ const pipeline = {
48
+ sync: vi.fn(() => dispose),
49
+ };
50
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation((callback) => {
51
+ callback(pipeline);
52
+ });
53
+ const { unmount } = render(_jsx(AppsScriptRouter, { children: _jsx("div", { children: "app" }) }));
54
+ await waitFor(() => {
55
+ expect(pipeline.sync).toHaveBeenCalledOnce();
56
+ });
57
+ unmount();
58
+ expect(dispose).toHaveBeenCalledOnce();
59
+ });
60
+ });
@@ -0,0 +1,104 @@
1
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { AppsScriptHistoryPipeline } from "@gasboost/client";
5
+ import { AppsScriptRouter } from "../src/AppsScriptRouter";
6
+
7
+ vi.mock("@gasboost/client", () => ({
8
+ AppsScriptHistoryPipeline: {
9
+ create: vi.fn(),
10
+ },
11
+ }));
12
+
13
+ describe("AppsScriptRouter", () => {
14
+ beforeEach(() => {
15
+ vi.resetAllMocks();
16
+ cleanup();
17
+ });
18
+
19
+ it("pipeline の生成が完了するまでは children を描画しない", () => {
20
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation(() => {});
21
+
22
+ render(
23
+ <AppsScriptRouter>
24
+ <div>app</div>
25
+ </AppsScriptRouter>,
26
+ );
27
+
28
+ expect(screen.queryByText("app")).toBeNull();
29
+ });
30
+
31
+ it("pipeline 生成後に同期を開始する", async () => {
32
+ const sync = vi.fn(() => vi.fn());
33
+
34
+ const pipeline = {
35
+ sync,
36
+ } as unknown as AppsScriptHistoryPipeline;
37
+
38
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation(
39
+ (callback) => {
40
+ callback(pipeline);
41
+ },
42
+ );
43
+
44
+ render(
45
+ <AppsScriptRouter>
46
+ <div>app</div>
47
+ </AppsScriptRouter>,
48
+ );
49
+
50
+ await waitFor(() => {
51
+ expect(sync).toHaveBeenCalledOnce();
52
+ });
53
+
54
+ expect(AppsScriptHistoryPipeline.create).toHaveBeenCalledOnce();
55
+ });
56
+
57
+ it("同期開始後に children を描画する", async () => {
58
+ const pipeline = {
59
+ sync: vi.fn(() => vi.fn()),
60
+ } as unknown as AppsScriptHistoryPipeline;
61
+
62
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation(
63
+ (callback) => {
64
+ callback(pipeline);
65
+ },
66
+ );
67
+
68
+ render(
69
+ <AppsScriptRouter>
70
+ <div>app</div>
71
+ </AppsScriptRouter>,
72
+ );
73
+
74
+ expect(await screen.findByText("app")).toBeTruthy();
75
+ });
76
+
77
+ it("アンマウント時に pipeline の監視を解除する", async () => {
78
+ const dispose = vi.fn();
79
+
80
+ const pipeline = {
81
+ sync: vi.fn(() => dispose),
82
+ } as unknown as AppsScriptHistoryPipeline;
83
+
84
+ vi.mocked(AppsScriptHistoryPipeline.create).mockImplementation(
85
+ (callback) => {
86
+ callback(pipeline);
87
+ },
88
+ );
89
+
90
+ const { unmount } = render(
91
+ <AppsScriptRouter>
92
+ <div>app</div>
93
+ </AppsScriptRouter>,
94
+ );
95
+
96
+ await waitFor(() => {
97
+ expect(pipeline.sync).toHaveBeenCalledOnce();
98
+ });
99
+
100
+ unmount();
101
+
102
+ expect(dispose).toHaveBeenCalledOnce();
103
+ });
104
+ });
@@ -0,0 +1,104 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { useAppsScriptJob } from "../src/useAppsScriptJob";
4
+ describe("useAppsScriptJob", () => {
5
+ it("jobStore の現在の snapshot を返す", () => {
6
+ const snapshot = {
7
+ status: "pending",
8
+ };
9
+ const jobStore = {
10
+ subscribe: vi.fn(() => vi.fn()),
11
+ getSnapshot: vi.fn(() => snapshot),
12
+ };
13
+ const { result } = renderHook(() => useAppsScriptJob(jobStore));
14
+ expect(result.current).toBe(snapshot);
15
+ });
16
+ it("jobStore の変更を subscribe する", async () => {
17
+ const snapshot = {
18
+ status: "pending",
19
+ };
20
+ const subscribe = vi.fn(() => vi.fn());
21
+ const jobStore = {
22
+ subscribe,
23
+ getSnapshot: vi.fn(() => snapshot),
24
+ };
25
+ renderHook(() => useAppsScriptJob(jobStore));
26
+ await waitFor(() => {
27
+ expect(subscribe).toHaveBeenCalledOnce();
28
+ });
29
+ });
30
+ it("jobStore の変更通知を受けると最新の snapshot を返す", async () => {
31
+ let listener;
32
+ let snapshot = {
33
+ status: "pending",
34
+ };
35
+ const jobStore = {
36
+ subscribe: vi.fn((handler) => {
37
+ listener = handler;
38
+ return vi.fn();
39
+ }),
40
+ getSnapshot: vi.fn(() => snapshot),
41
+ };
42
+ const { result } = renderHook(() => useAppsScriptJob(jobStore));
43
+ await waitFor(() => {
44
+ expect(listener).toBeDefined();
45
+ });
46
+ snapshot = {
47
+ status: "completed",
48
+ };
49
+ act(() => {
50
+ listener?.();
51
+ });
52
+ expect(result.current).toEqual({
53
+ status: "completed",
54
+ });
55
+ });
56
+ it("アンマウント時に subscribe の解除関数を呼ぶ", async () => {
57
+ const snapshot = {
58
+ status: "pending",
59
+ };
60
+ const unsubscribe = vi.fn();
61
+ const jobStore = {
62
+ subscribe: vi.fn(() => unsubscribe),
63
+ getSnapshot: vi.fn(() => snapshot),
64
+ };
65
+ const { unmount } = renderHook(() => useAppsScriptJob(jobStore));
66
+ await waitFor(() => {
67
+ expect(jobStore.subscribe).toHaveBeenCalledOnce();
68
+ });
69
+ unmount();
70
+ expect(unsubscribe).toHaveBeenCalledOnce();
71
+ });
72
+ it("jobStore が変更された場合は新しい store を subscribe する", async () => {
73
+ const firstSnapshot = {
74
+ status: "first",
75
+ };
76
+ const secondSnapshot = {
77
+ status: "second",
78
+ };
79
+ const firstUnsubscribe = vi.fn();
80
+ const firstStore = {
81
+ subscribe: vi.fn(() => firstUnsubscribe),
82
+ getSnapshot: vi.fn(() => firstSnapshot),
83
+ };
84
+ const secondStore = {
85
+ subscribe: vi.fn(() => vi.fn()),
86
+ getSnapshot: vi.fn(() => secondSnapshot),
87
+ };
88
+ const { rerender } = renderHook(({ store }) => useAppsScriptJob(store), {
89
+ initialProps: {
90
+ store: firstStore,
91
+ },
92
+ });
93
+ await waitFor(() => {
94
+ expect(firstStore.subscribe).toHaveBeenCalledOnce();
95
+ });
96
+ rerender({
97
+ store: secondStore,
98
+ });
99
+ await waitFor(() => {
100
+ expect(secondStore.subscribe).toHaveBeenCalledOnce();
101
+ });
102
+ expect(firstUnsubscribe).toHaveBeenCalledOnce();
103
+ });
104
+ });
@@ -0,0 +1,139 @@
1
+ import type { AppsScriptJobStore } from "@gasboost/client";
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { useAppsScriptJob } from "../src/useAppsScriptJob";
5
+
6
+ describe("useAppsScriptJob", () => {
7
+ it("jobStore の現在の snapshot を返す", () => {
8
+ const snapshot = {
9
+ status: "pending",
10
+ };
11
+
12
+ const jobStore = {
13
+ subscribe: vi.fn(() => vi.fn()),
14
+ getSnapshot: vi.fn(() => snapshot),
15
+ } as unknown as AppsScriptJobStore;
16
+
17
+ const { result } = renderHook(() => useAppsScriptJob(jobStore));
18
+
19
+ expect(result.current).toBe(snapshot);
20
+ });
21
+
22
+ it("jobStore の変更を subscribe する", async () => {
23
+ const snapshot = {
24
+ status: "pending",
25
+ };
26
+
27
+ const subscribe = vi.fn(() => vi.fn());
28
+
29
+ const jobStore = {
30
+ subscribe,
31
+ getSnapshot: vi.fn(() => snapshot),
32
+ } as unknown as AppsScriptJobStore;
33
+
34
+ renderHook(() => useAppsScriptJob(jobStore));
35
+
36
+ await waitFor(() => {
37
+ expect(subscribe).toHaveBeenCalledOnce();
38
+ });
39
+ });
40
+
41
+ it("jobStore の変更通知を受けると最新の snapshot を返す", async () => {
42
+ let listener: (() => void) | undefined;
43
+
44
+ let snapshot = {
45
+ status: "pending",
46
+ };
47
+
48
+ const jobStore = {
49
+ subscribe: vi.fn((handler: () => void) => {
50
+ listener = handler;
51
+ return vi.fn();
52
+ }),
53
+ getSnapshot: vi.fn(() => snapshot),
54
+ } as unknown as AppsScriptJobStore;
55
+
56
+ const { result } = renderHook(() => useAppsScriptJob(jobStore));
57
+
58
+ await waitFor(() => {
59
+ expect(listener).toBeDefined();
60
+ });
61
+
62
+ snapshot = {
63
+ status: "completed",
64
+ };
65
+
66
+ act(() => {
67
+ listener?.();
68
+ });
69
+
70
+ expect(result.current).toEqual({
71
+ status: "completed",
72
+ });
73
+ });
74
+
75
+ it("アンマウント時に subscribe の解除関数を呼ぶ", async () => {
76
+ const snapshot = {
77
+ status: "pending",
78
+ };
79
+
80
+ const unsubscribe = vi.fn();
81
+
82
+ const jobStore = {
83
+ subscribe: vi.fn(() => unsubscribe),
84
+ getSnapshot: vi.fn(() => snapshot),
85
+ } as unknown as AppsScriptJobStore;
86
+
87
+ const { unmount } = renderHook(() => useAppsScriptJob(jobStore));
88
+
89
+ await waitFor(() => {
90
+ expect(jobStore.subscribe).toHaveBeenCalledOnce();
91
+ });
92
+
93
+ unmount();
94
+
95
+ expect(unsubscribe).toHaveBeenCalledOnce();
96
+ });
97
+
98
+ it("jobStore が変更された場合は新しい store を subscribe する", async () => {
99
+ const firstSnapshot = {
100
+ status: "first",
101
+ };
102
+
103
+ const secondSnapshot = {
104
+ status: "second",
105
+ };
106
+
107
+ const firstUnsubscribe = vi.fn();
108
+
109
+ const firstStore = {
110
+ subscribe: vi.fn(() => firstUnsubscribe),
111
+ getSnapshot: vi.fn(() => firstSnapshot),
112
+ } as unknown as AppsScriptJobStore;
113
+
114
+ const secondStore = {
115
+ subscribe: vi.fn(() => vi.fn()),
116
+ getSnapshot: vi.fn(() => secondSnapshot),
117
+ } as unknown as AppsScriptJobStore;
118
+
119
+ const { rerender } = renderHook(({ store }) => useAppsScriptJob(store), {
120
+ initialProps: {
121
+ store: firstStore,
122
+ },
123
+ });
124
+
125
+ await waitFor(() => {
126
+ expect(firstStore.subscribe).toHaveBeenCalledOnce();
127
+ });
128
+
129
+ rerender({
130
+ store: secondStore,
131
+ });
132
+
133
+ await waitFor(() => {
134
+ expect(secondStore.subscribe).toHaveBeenCalledOnce();
135
+ });
136
+
137
+ expect(firstUnsubscribe).toHaveBeenCalledOnce();
138
+ });
139
+ });
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "declaration": true,
7
+ "declarationMap": false,
8
+ "sourceMap": false,
9
+ "noEmit": false
10
+ },
11
+ "include": ["src"],
12
+ "exclude": ["tests", "dist"]
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx"
5
+ },
6
+ "include": ["src", "tests"]
7
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "jsdom",
6
+ },
7
+ });