From e7ce53a206bd7c978c7e93b0cb659fc1e0e6fdeb Mon Sep 17 00:00:00 2001 From: Project7 Date: Thu, 28 May 2026 02:00:53 +0000 Subject: [PATCH 1/3] [#224] Fix cartoon publish contentType propagation from StoriesPage Add storyContentTypes and walletAddress to handlePublish's useCallback dependency array. Previously the callback captured stale initial values, causing cartoon genesis publishes to omit contentType: "cartoon" if the story metadata hadn't been available at callback creation time. Fixes the eslint missing-dependency warning for this callback. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/web/components/StoriesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx index 228ad1d..cc892f8 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -302,7 +302,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { setPublishProgress(""); }, 3000); } - }, [authFetch]); + }, [authFetch, storyContentTypes, walletAddress]); const handleDestroySession = useCallback((name: string) => { if (name.startsWith("_new_")) { From 7a0a79cb7e29d7d30db94e1c7f32c8e5bf04f4db Mon Sep 17 00:00:00 2001 From: Project7 Date: Thu, 28 May 2026 02:03:46 +0000 Subject: [PATCH 2/3] [#224] Add callback boundary test for contentType propagation Add React component test that simulates the stale closure scenario: renders a component with useCallback depending on storyContentTypes, updates metadata to cartoon, then publishes genesis and verifies contentType: "cartoon" is in the payload. Also verifies cartoon plot omits contentType. This test would fail without the dependency array fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/web/components/publish-callback.test.tsx | 63 ++++++++++++++++++++ app/web/lib/publish-helpers.test.ts | 15 +++++ 2 files changed, 78 insertions(+) create mode 100644 app/web/components/publish-callback.test.tsx diff --git a/app/web/components/publish-callback.test.tsx b/app/web/components/publish-callback.test.tsx new file mode 100644 index 0000000..41b348e --- /dev/null +++ b/app/web/components/publish-callback.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import { useState, useCallback } from "react"; +import { getContentTypeForPublish } from "../lib/publish-helpers"; + +afterEach(cleanup); + +function TestPublishComponent({ authFetch }: { authFetch: (url: string, opts?: RequestInit) => void }) { + const [storyContentTypes, setStoryContentTypes] = useState>({}); + + const handlePublish = useCallback((storyName: string, storylineId: number | undefined) => { + const ct = getContentTypeForPublish(storyContentTypes, storyName, storylineId); + const payload = { storyName, ...(ct ? { contentType: ct } : {}) }; + authFetch("/api/publish/file", { + method: "POST", + body: JSON.stringify(payload), + }); + }, [authFetch, storyContentTypes]); + + return ( +
+ + + +
+ ); +} + +describe("publish callback boundary (stale closure regression)", () => { + it("cartoon genesis includes contentType after metadata update", () => { + const authFetch = vi.fn(); + render(); + + fireEvent.click(screen.getByTestId("publish-genesis")); + expect(authFetch).toHaveBeenCalledTimes(1); + const firstPayload = JSON.parse(authFetch.mock.calls[0][1].body); + expect(firstPayload.contentType).toBeUndefined(); + + act(() => { fireEvent.click(screen.getByTestId("set-cartoon")); }); + + fireEvent.click(screen.getByTestId("publish-genesis")); + expect(authFetch).toHaveBeenCalledTimes(2); + const secondPayload = JSON.parse(authFetch.mock.calls[1][1].body); + expect(secondPayload.contentType).toBe("cartoon"); + }); + + it("cartoon plot omits contentType even after metadata update", () => { + const authFetch = vi.fn(); + render(); + + act(() => { fireEvent.click(screen.getByTestId("set-cartoon")); }); + fireEvent.click(screen.getByTestId("publish-plot")); + + const payload = JSON.parse(authFetch.mock.calls[0][1].body); + expect(payload.contentType).toBeUndefined(); + }); +}); diff --git a/app/web/lib/publish-helpers.test.ts b/app/web/lib/publish-helpers.test.ts index 2ffca99..9ad3288 100644 --- a/app/web/lib/publish-helpers.test.ts +++ b/app/web/lib/publish-helpers.test.ts @@ -21,4 +21,19 @@ describe("getContentTypeForPublish", () => { it("returns undefined for unknown story", () => { expect(getContentTypeForPublish({}, "unknown", undefined)).toBeUndefined(); }); + + it("returns cartoon after metadata update (simulates stale closure fix)", () => { + let storyContentTypes: Record = {}; + + const buildPayload = (storyName: string, storylineId: number | undefined) => { + const ct = getContentTypeForPublish(storyContentTypes, storyName, storylineId); + return ct ? { contentType: ct } : {}; + }; + + expect(buildPayload("my-cartoon", undefined)).toEqual({}); + + storyContentTypes = { "my-cartoon": "cartoon" }; + + expect(buildPayload("my-cartoon", undefined)).toEqual({ contentType: "cartoon" }); + }); }); From 1f8e58a36fe6a0f09a271c502c653eeb03cad0b3 Mon Sep 17 00:00:00 2001 From: Project7 Date: Thu, 28 May 2026 02:05:08 +0000 Subject: [PATCH 3/3] [#224] Add source guard test for production dependency array Read StoriesPage.tsx source and assert handlePublish's useCallback dependency array includes storyContentTypes and walletAddress. This test fails if the production code regresses back to [authFetch] only, regardless of test-component correctness. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/web/components/publish-callback.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/web/components/publish-callback.test.tsx b/app/web/components/publish-callback.test.tsx index 41b348e..7aa99fa 100644 --- a/app/web/components/publish-callback.test.tsx +++ b/app/web/components/publish-callback.test.tsx @@ -32,6 +32,25 @@ function TestPublishComponent({ authFetch }: { authFetch: (url: string, opts?: R ); } +describe("StoriesPage.handlePublish dependency array (source guard)", () => { + it("production handlePublish includes storyContentTypes and walletAddress in deps", async () => { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync( + path.resolve(__dirname, "StoriesPage.tsx"), + "utf-8", + ); + + const handlePublishMatch = source.match( + /const handlePublish = useCallback\([\s\S]*?\}, \[([^\]]+)\]\)/, + ); + expect(handlePublishMatch).toBeTruthy(); + const deps = handlePublishMatch![1]; + expect(deps).toContain("storyContentTypes"); + expect(deps).toContain("walletAddress"); + }); +}); + describe("publish callback boundary (stale closure regression)", () => { it("cartoon genesis includes contentType after metadata update", () => { const authFetch = vi.fn();