@gasboost/react 0.1.0 → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gasboost
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # @gasboost/react
2
+
3
+ Google Apps Script Web アプリケーション向けの React integration です。
4
+
5
+ `@gasboost/client` が提供する navigation と Job Store を React から利用するためのコンポーネントと Hook を提供します。
6
+
7
+ 現在、以下の API を公開しています。
8
+
9
+ - `AppsScriptRouter`
10
+ - `useAppsScriptJob`
11
+
12
+ ## インストール
13
+
14
+ ```bash
15
+ pnpm add @gasboost/react @gasboost/client react
16
+ ```
17
+
18
+ npm:
19
+
20
+ ```bash
21
+ npm install @gasboost/react @gasboost/client react
22
+ ```
23
+
24
+ 現在 React 19 を対象としています。
25
+
26
+ ## AppsScriptRouter
27
+
28
+ `AppsScriptRouter` は、Google Apps Script Web アプリケーションの Container History と iframe 側の History を同期します。
29
+
30
+ ```tsx
31
+ import { AppsScriptRouter } from "@gasboost/react";
32
+
33
+ export function App() {
34
+ return (
35
+ <AppsScriptRouter>
36
+ <main>Application</main>
37
+ </AppsScriptRouter>
38
+ );
39
+ }
40
+ ```
41
+
42
+ 内部では `@gasboost/client` の `AppsScriptHistoryPipeline` を利用します。
43
+
44
+ ```text
45
+ google.script.history / google.script.url
46
+
47
+ AppsScriptHistoryPipeline
48
+
49
+ iframe History
50
+
51
+ React
52
+ ```
53
+
54
+ ## 初期化
55
+
56
+ `AppsScriptRouter` は History Pipeline の生成と同期が完了するまで children を描画しません。
57
+
58
+ ```tsx
59
+ <AppsScriptRouter>
60
+ <App />
61
+ </AppsScriptRouter>
62
+ ```
63
+
64
+ 概念的には次の順序で初期化されます。
65
+
66
+ ```text
67
+ AppsScriptRouter mount
68
+
69
+ AppsScriptHistoryPipeline.create()
70
+
71
+ pipeline.sync()
72
+
73
+ History synchronization ready
74
+
75
+ children render
76
+ ```
77
+
78
+ これにより、GAS Container と iframe の navigation state を同期してからアプリケーションを描画します。
79
+
80
+ ## Cleanup
81
+
82
+ `AppsScriptRouter` が unmount されると、History Pipeline が返した監視解除関数を実行します。
83
+
84
+ そのため、Router の破棄後に navigation の監視が残り続けることはありません。
85
+
86
+ ## useAppsScriptJob
87
+
88
+ `useAppsScriptJob` は `AppsScriptJobStore` を React から購読する Hook です。
89
+
90
+ 内部では React の `useSyncExternalStore` を利用します。
91
+
92
+ ```tsx
93
+ import { useAppsScriptJob } from "@gasboost/react";
94
+ ```
95
+
96
+ 例えば `@gasboost/client` の Job Store を渡すと、Job 一覧の変更に合わせて React component を再描画できます。
97
+
98
+ ```tsx
99
+ const snapshot = useAppsScriptJob(jobStore);
100
+ ```
101
+
102
+ `useAppsScriptJob` は次の interface を持つ Store を受け取ります。
103
+
104
+ ```ts
105
+ interface AppsScriptJobStore {
106
+ subscribe(listener: () => void): () => void;
107
+ getSnapshot(): AppsScriptJob[];
108
+ }
109
+ ```
110
+
111
+ Store が変更通知を行うと、最新の snapshot が React に反映されます。
112
+
113
+ ## Store の切り替え
114
+
115
+ `useAppsScriptJob` に渡す Store が変更された場合、以前の Store の購読を解除し、新しい Store を購読します。
116
+
117
+ component が unmount された場合も購読解除関数が呼び出されます。
118
+
119
+ ## Job UI
120
+
121
+ `@gasboost/react` は Job の表示 UI 自体は提供しません。
122
+
123
+ `useAppsScriptJob` が返す snapshot を利用して、アプリケーション側で自由に UI を構築できます。
124
+
125
+ 例えば:
126
+
127
+ ```tsx
128
+ const jobs = useAppsScriptJob(jobStore);
129
+
130
+ return (
131
+ <ul>
132
+ {jobs.map((job) => (
133
+ <li key={job.id}>
134
+ {job.label}: {job.status}
135
+ </li>
136
+ ))}
137
+ </ul>
138
+ );
139
+ ```
140
+
141
+ Job の状態管理、cancel、retry などの実体は `@gasboost/client` が担当します。
142
+
143
+ ## 責務
144
+
145
+ `@gasboost/react` が担当するもの:
146
+
147
+ - `AppsScriptHistoryPipeline` の React lifecycle への統合
148
+ - History 同期完了後の描画制御
149
+ - unmount 時の History 監視解除
150
+ - `AppsScriptJobStore` と React の同期
151
+ - Store の購読と解除
152
+
153
+ 以下は担当しません。
154
+
155
+ - RPC transport
156
+ - Job Queue / Runner
157
+ - RPC 型推論
158
+ - Google Apps Script backend
159
+ - GAS build
160
+
161
+ これらはそれぞれ `@gasboost/client`、`@gasboost/app`、`@gasboost/vite` が担当します。
162
+
163
+ ## 関連パッケージ
164
+
165
+ - `@gasboost/client` — RPC、Job、History
166
+ - `@gasboost/app` — backend runtime と RPC 契約
167
+ - `@gasboost/vite` — GAS build と Local RPC
168
+
169
+ ## License
170
+
171
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gasboost/react",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "React integration for Google Apps Script web applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,10 @@
17
17
  "web-app",
18
18
  "gasboost"
19
19
  ],
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
20
24
  "main": "./dist/index.js",
21
25
  "types": "./dist/index.d.ts",
22
26
  "exports": {
@@ -26,7 +30,7 @@
26
30
  }
27
31
  },
28
32
  "dependencies": {
29
- "@gasboost/client": "0.1.0"
33
+ "@gasboost/client": "0.1.1"
30
34
  },
31
35
  "peerDependencies": {
32
36
  "react": "^19"
@@ -1,26 +0,0 @@
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 DELETED
@@ -1,2 +0,0 @@
1
- export { AppsScriptRouter } from "./AppsScriptRouter";
2
- export { useAppsScriptJob } from "./useAppsScriptJob";
@@ -1,9 +0,0 @@
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
- }
@@ -1,60 +0,0 @@
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
- });
@@ -1,104 +0,0 @@
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
- });
@@ -1,104 +0,0 @@
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
- });
@@ -1,139 +0,0 @@
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
- });
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "jsx": "react-jsx"
5
- },
6
- "include": ["src", "tests"]
7
- }
package/vitest.config.mts DELETED
@@ -1,7 +0,0 @@
1
- import { defineConfig } from "vitest/config";
2
-
3
- export default defineConfig({
4
- test: {
5
- environment: "jsdom",
6
- },
7
- });