Skip to content

Commit d04150a

Browse files
refactor(webapp): move version history and diagram loading to TanStack Query (#821)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5d90428 commit d04150a

61 files changed

Lines changed: 3376 additions & 1700 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@tumaet/webapp": patch
3+
---
4+
5+
No visible change to the editor. Under the hood, the web app's version
6+
history — the list, version snapshots, and saving/renaming/deleting/restoring
7+
versions — now runs through one shared data layer instead of hand-written
8+
fetching, so the version panel refreshes and reconciles more consistently
9+
across tabs and collaborators.

pnpm-lock.yaml

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

standalone/webapp/.storybook/preview.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { DocsContainer } from "@storybook/addon-docs/blocks"
55
import { themes } from "storybook/theming"
66
import { addons } from "storybook/preview-api"
77
import { withTanStackRouter } from "../src/stories/_support/webapp"
8+
import { QueryClientProvider } from "@tanstack/react-query"
9+
import { storybookQueryClient } from "../src/stories/_support/queryClient"
10+
import { VersionRepositoryProvider } from "../src/contexts/VersionRepositoryContext"
811

912
type DocsContainerCtx = ComponentProps<typeof DocsContainer>["context"]
1013

@@ -213,7 +216,24 @@ const preview: Preview = {
213216
},
214217
},
215218
tags: ["autodocs"],
219+
// One clean query cache per story: stories that share a query key but inject
220+
// different data would otherwise read each other's cached results.
221+
beforeEach: () => {
222+
storybookQueryClient.clear()
223+
},
216224
decorators: [
225+
// TanStack Query context for components that read server state through
226+
// the query hooks (versioning UI, share flow, legal pages). The shared
227+
// client lives in _support/queryClient so beforeEach hooks can reset it.
228+
// Query cache + the version backend the story's UI talks to (the editor
229+
// routes supply the latter in production).
230+
(Story) => (
231+
<QueryClientProvider client={storybookQueryClient}>
232+
<VersionRepositoryProvider kind="remote">
233+
<Story />
234+
</VersionRepositoryProvider>
235+
</QueryClientProvider>
236+
),
217237
// TanStack router context so any component using <Link>/useNavigate/
218238
// useLocation renders without crashing. Per-story routes and the active
219239
// location are set via the `tanstackRouter` parameter.

standalone/webapp/README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,28 @@ Part of the Apollon monorepo — run it from the repo root with `pnpm dev`, not
66

77
## Stack
88

9-
React 19, TypeScript, Vite, the shadcn-style [`@tumaet/ui`](../../packages/ui) design system (Base UI primitives + Tailwind v4), Storybook, Vitest, Playwright (visual + e2e).
9+
React 19, TypeScript, Vite, the shadcn-style [`@tumaet/ui`](../../packages/ui) design system (Base UI primitives + Tailwind v4), [TanStack Query](https://tanstack.com/query) for version-history server state, Storybook, Vitest, Playwright (visual + e2e).
10+
11+
## Debugging server state
12+
13+
Version history — the list, the immutable snapshot bodies, and the
14+
create/rename/delete/restore mutations — goes through TanStack Query (see
15+
[`src/queries`](src/queries) and the boundary note in
16+
[`src/queryClient.ts`](src/queryClient.ts)). The editor's initial diagram
17+
body is deliberately NOT a query: it is a one-shot seed that Yjs owns after
18+
mount, so it must never be refetched or served from a cache — see
19+
[`src/hooks/useDiagramSeed.ts`](src/hooks/useDiagramSeed.ts).
20+
21+
The Query Devtools are **off by default**: their floating button sits
22+
bottom-right, on top of the editor's minimap, and every other corner is taken
23+
by the editor's own chrome. Enable them per browser from the console, then
24+
reload:
25+
26+
```js
27+
localStorage.setItem("apollon:query-devtools", "1")
28+
```
29+
30+
They are stripped from production builds regardless.
1031

1132
## Scripts
1233

standalone/webapp/eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import pluginJs from "@eslint/js"
44
import tseslint from "typescript-eslint"
55
import eslintReact from "@eslint-react/eslint-plugin"
66
import reactHooks from "eslint-plugin-react-hooks"
7+
import pluginQuery from "@tanstack/eslint-plugin-query"
78

89
/** @type {import('eslint').Linter.Config[]} */
910
export default [
@@ -26,6 +27,9 @@ export default [
2627
{ languageOptions: { globals: globals.browser } },
2728
pluginJs.configs.recommended,
2829
...tseslint.configs.recommended,
30+
// TanStack Query correctness rules (exhaustive query keys, stable
31+
// QueryClient, no misuse of mutation results).
32+
...pluginQuery.configs["flat/recommended"],
2933
// recommended-typescript disables the prop-types rules TypeScript already enforces.
3034
eslintReact.configs["recommended-typescript"],
3135
{

standalone/webapp/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@resvg/resvg-wasm": "catalog:",
5050
"@tailwindcss/vite": "catalog:",
5151
"@tanstack/history": "1.162.0",
52+
"@tanstack/react-query": "5.101.2",
5253
"@tanstack/react-router": "1.170.16",
5354
"@tumaet/apollon": "workspace:*",
5455
"@tumaet/ui": "workspace:*",
@@ -80,6 +81,8 @@
8081
"@storybook/addon-themes": "catalog:",
8182
"@storybook/addon-vitest": "catalog:",
8283
"@storybook/react-vite": "catalog:",
84+
"@tanstack/eslint-plugin-query": "5.100.4",
85+
"@tanstack/react-query-devtools": "5.101.2",
8386
"@tanstack/router-plugin": "1.168.18",
8487
"@testing-library/react": "catalog:",
8588
"@testing-library/user-event": "14.6.1",
Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,42 @@
11
import React, { ReactNode } from "react"
2+
import { QueryClientProvider } from "@tanstack/react-query"
3+
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
24
import { EditorProvider, ModalProvider } from "@/contexts"
5+
import { queryClient } from "@/queryClient"
36

47
interface Props {
58
children: ReactNode
69
}
710

11+
/**
12+
* Query Devtools are opt-in: they float a toggle button over the editor's own
13+
* floating chrome, which is in the way far more often than it is useful. Turn
14+
* them on per browser with
15+
*
16+
* localStorage.setItem("apollon:query-devtools", "1")
17+
*
18+
* and reload. Read once at module load — the flag is a debugging switch, not
19+
* reactive state. Production is unaffected either way: the package swaps
20+
* itself for a no-op export when `NODE_ENV !== "development"`.
21+
*/
22+
const SHOW_QUERY_DEVTOOLS =
23+
import.meta.env.DEV &&
24+
(() => {
25+
try {
26+
return localStorage.getItem("apollon:query-devtools") === "1"
27+
} catch {
28+
// Storage throws when cookies / site data are blocked.
29+
return false
30+
}
31+
})()
32+
833
export const AppProviders: React.FC<Props> = ({ children }) => {
934
return (
10-
<EditorProvider>
11-
<ModalProvider>{children}</ModalProvider>
12-
</EditorProvider>
35+
<QueryClientProvider client={queryClient}>
36+
<EditorProvider>
37+
<ModalProvider>{children}</ModalProvider>
38+
</EditorProvider>
39+
{SHOW_QUERY_DEVTOOLS && <ReactQueryDevtools initialIsOpen={false} />}
40+
</QueryClientProvider>
1341
)
1442
}

standalone/webapp/src/components/modals/ShareDashboardModal.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest"
22
import { fireEvent, screen, waitFor } from "@testing-library/react"
33
import { ShareDashboardModal } from "./ShareDashboardModal"
4+
import { QueryClientProvider } from "@tanstack/react-query"
45
import { renderWithRouter } from "@/test/renderWithRouter"
6+
import { createTestQueryClient } from "@/test/queryTestUtils"
57
import { usePersistenceModelStore } from "@/stores/usePersistenceModelStore"
68
import { DiagramView } from "@/types"
79

@@ -89,7 +91,14 @@ describe("ShareDashboardModal", () => {
8991

9092
const { router } = renderWithRouter(
9193
<ShareDashboardModal modelId="diagram-1" />,
92-
{ routePaths: ["/", "/shared/$diagramId"] }
94+
{
95+
routePaths: ["/", "/shared/$diagramId"],
96+
wrapper: (children) => (
97+
<QueryClientProvider client={createTestQueryClient()}>
98+
{children}
99+
</QueryClientProvider>
100+
),
101+
}
93102
)
94103

95104
fireEvent.click(await screen.findByRole("button", { name: "Create" }))

standalone/webapp/src/components/modals/ShareModal.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest"
2-
import { fireEvent, render, screen } from "@testing-library/react"
2+
import { fireEvent, screen } from "@testing-library/react"
3+
import { renderWithQuery } from "@/test/queryTestUtils"
34

45
const { sharedIdRef, createMock } = vi.hoisted(() => ({
56
sharedIdRef: { value: undefined as string | undefined },
@@ -46,7 +47,7 @@ beforeEach(() => {
4647
describe("ShareModal", () => {
4748
it("opens straight on the link for an already-shared diagram, with embed", () => {
4849
sharedIdRef.value = "shared-xyz"
49-
render(<ShareModal />)
50+
renderWithQuery(<ShareModal />)
5051

5152
expect(screen.getByLabelText("Copy link")).toBeTruthy()
5253
expect(screen.getByText("Embed")).toBeTruthy()
@@ -56,7 +57,7 @@ describe("ShareModal", () => {
5657

5758
it("creates the shared diagram exactly once, then shows the link", async () => {
5859
createMock.mockResolvedValue({ id: "new-1" })
59-
render(<ShareModal />)
60+
renderWithQuery(<ShareModal />)
6061

6162
fireEvent.click(screen.getByRole("button", { name: "Create share link" }))
6263
// After creation the link appears and the create button is gone.

standalone/webapp/src/components/modals/useShareableDiagram.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef, useState } from "react"
2+
import { useMutation } from "@tanstack/react-query"
23
import { toast } from "react-toastify"
34
import type { UMLModel } from "@tumaet/apollon"
45
import { DiagramView } from "@/types"
@@ -40,6 +41,10 @@ export function useShareableDiagram(
4041

4142
const link = diagramId ? buildSharedDiagramUrl(diagramId, mode) : ""
4243

44+
const createDiagramMutation = useMutation({
45+
mutationFn: (model: UMLModel) => DiagramApiClient.createDiagram(model),
46+
})
47+
4348
const create = async (name: string) => {
4449
if (!modelData) {
4550
toast.error("This diagram can't be shared right now.")
@@ -52,7 +57,7 @@ export function useShareableDiagram(
5257
trimmed && trimmed !== modelData.title
5358
? { ...modelData, title: trimmed }
5459
: modelData
55-
const { id } = await DiagramApiClient.createDiagram(model)
60+
const { id } = await createDiagramMutation.mutateAsync(model)
5661
addSharedDiagramEntry(id)
5762
setDiagramId(id)
5863
setMode(DiagramView.COLLABORATE)

0 commit comments

Comments
 (0)