From 4e729f815bebb06f06adf8751c433c3a1d70d888 Mon Sep 17 00:00:00 2001 From: Mark Street Date: Fri, 15 May 2026 19:14:01 +0100 Subject: [PATCH 1/5] Fix stale (unsaved) page title --- frontend/src/lib/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 009f99824..060739fba 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -61,7 +61,7 @@ export function useWarnBeforeUnload( enabledRef.current = enabled; messageRef.current = message; - useEffect(() => { + useLayoutEffect(() => { navigationWarning.enabled = enabledRef.current; navigationWarning.message = messageRef.current; let shouldLeaveAfterPop = false; From c02f4b4d75ac3a7f364f510b2580b807e6a1fea0 Mon Sep 17 00:00:00 2001 From: Mark Street Date: Fri, 15 May 2026 20:06:03 +0100 Subject: [PATCH 2/5] Fix TimeAgo hydration warning --- frontend/src/components/TimeAgo.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/components/TimeAgo.tsx b/frontend/src/components/TimeAgo.tsx index 5c0498ec7..47cb5410c 100644 --- a/frontend/src/components/TimeAgo.tsx +++ b/frontend/src/components/TimeAgo.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useEffect, useState } from "react"; + import DateObject from "react-date-object"; import ReactTimeAgo from "react-timeago"; @@ -12,7 +16,22 @@ export default function TimeAgo({ }: { date: string; }) { + const [mounted, setMounted] = useState(false); + const dateTime = new Date(date).toISOString(); + const fallbackTitle = dateTime.substring(0, 19).replace("T", " "); const title = formatDateString(date); + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + return ; } From 8a8fc92c599ba09dd538c212467565ca0637b44a Mon Sep 17 00:00:00 2001 From: Mark Street Date: Fri, 15 May 2026 20:10:10 +0100 Subject: [PATCH 3/5] Simplify Fork behaviour --- backend/coreapp/tests/test_scratch.py | 9 ++- backend/coreapp/views/scratch.py | 3 +- frontend/next.config.js | 2 + .../Scratch/ScratchToolbar.module.scss | 4 + .../src/components/Scratch/ScratchToolbar.tsx | 78 +++++++++---------- frontend/src/lib/api.ts | 7 +- 6 files changed, 57 insertions(+), 46 deletions(-) diff --git a/backend/coreapp/tests/test_scratch.py b/backend/coreapp/tests/test_scratch.py index 49beba8d8..caf13f48b 100644 --- a/backend/coreapp/tests/test_scratch.py +++ b/backend/coreapp/tests/test_scratch.py @@ -484,7 +484,7 @@ def test_fork_scratch(self) -> None: ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - new_claim_token = response.json()["claim_token"] + self.assertNotIn("claim_token", response.json()) new_slug = response.json()["slug"] scratch = Scratch.objects.get(slug=slug) @@ -496,9 +496,10 @@ def test_fork_scratch(self) -> None: # Make sure the name carried over to the fork self.assertEqual(scratch.name, fork.name) - # Ensure the new scratch has a (unique) claim token - self.assertIsNotNone(new_claim_token) - self.assertIsNot(new_claim_token, old_claim_token) + # Ensure the new scratch is owned by the profile that forked it. + self.assertEqual(fork.owner_id, self.client.session["profile_id"]) + self.assertEqual(response.json()["owner"]["id"], fork.owner_id) + self.assertNotEqual(fork.claim_token, old_claim_token) class ScratchDetailTests(BaseTestCase): diff --git a/backend/coreapp/views/scratch.py b/backend/coreapp/views/scratch.py index b2a7a0ce3..619f7d238 100644 --- a/backend/coreapp/views/scratch.py +++ b/backend/coreapp/views/scratch.py @@ -505,6 +505,7 @@ def fork(self, request: Request, pk: str) -> Response: libraries = [Library(**lib) for lib in ser.validated_data["libraries"]] new_scratch = ser.save( parent=parent, + owner=request.profile, target_assembly=parent.target_assembly, platform=parent.platform, libraries=libraries, @@ -513,7 +514,7 @@ def fork(self, request: Request, pk: str) -> Response: compile_scratch_update_score(new_scratch) return Response( - ClaimableScratchSerializer(new_scratch, context={"request": request}).data, + ScratchSerializer(new_scratch, context={"request": request}).data, status=status.HTTP_201_CREATED, ) diff --git a/frontend/next.config.js b/frontend/next.config.js index 27887dde9..2f7de1f3f 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -114,6 +114,8 @@ let app = { NEXT_PUBLIC_COMMIT_HASH: process.env.GIT_HASH ?? "abc123", OBJDIFF_BASE: process.env.OBJDIFF_BASE, }, + allowedDevOrigins: ['carbon'], + }; if (process.env.ANALYZE === "true") { diff --git a/frontend/src/components/Scratch/ScratchToolbar.module.scss b/frontend/src/components/Scratch/ScratchToolbar.module.scss index e1f7733d0..c1a1bfe16 100644 --- a/frontend/src/components/Scratch/ScratchToolbar.module.scss +++ b/frontend/src/components/Scratch/ScratchToolbar.module.scss @@ -117,6 +117,10 @@ nav.breadcrumbs { color: var(--g2000); background: var(--g400); } + + @media (width >= 768px) { + min-width: 64px; + } } } } diff --git a/frontend/src/components/Scratch/ScratchToolbar.tsx b/frontend/src/components/Scratch/ScratchToolbar.tsx index 8ddfc9429..3770a3666 100644 --- a/frontend/src/components/Scratch/ScratchToolbar.tsx +++ b/frontend/src/components/Scratch/ScratchToolbar.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { + CheckIcon, DownloadIcon, FileIcon, IterationsIcon, @@ -32,9 +33,7 @@ import PlatformLink from "../PlatformLink"; import { SpecialKey, useShortcut } from "../Shortcut"; import UserAvatar from "../user/UserAvatar"; -import useFuzzySaveCallback, { - FuzzySaveAction, -} from "./hooks/useFuzzySaveCallback"; +import useFuzzySaveCallback from "./hooks/useFuzzySaveCallback"; import styles from "./ScratchToolbar.module.scss"; const ACTIVE_MS = 1000 * 60; @@ -195,25 +194,36 @@ function Actions({ }: Props) { const userIsYou = api.useUserIsYou(); const forkScratch = api.useForkScratchAndGo(scratch); - const [fuzzySaveAction, fuzzySaveScratch] = useFuzzySaveCallback( - scratch, - setScratch, - ); + const [, fuzzySaveScratch] = useFuzzySaveCallback(scratch, setScratch); const [isSaving, setIsSaving] = useState(false); const [isForking, setIsForking] = useState(false); - const canSave = scratch.owner && userIsYou(scratch.owner); + const canSave = !!(scratch.owner && userIsYou(scratch.owner)); + const isSaved = api.useIsScratchSaved(scratch); const platform = api.usePlatform(scratch.platform); + const saveScratch = async () => { + if (!canSave || isSaved || isSaving) return; + + setIsSaving(true); + await fuzzySaveScratch(); + setIsSaving(false); + saveCallback(); + }; + + const forkCurrentScratch = async () => { + if (isForking) return; + + setIsForking(true); + await forkScratch(); + setIsForking(false); + saveCallback(); + }; + const fuzzyShortcut = useShortcut( [SpecialKey.CTRL_COMMAND, "S"], - async () => { - setIsSaving(true); - await fuzzySaveScratch(); - setIsSaving(false); - saveCallback(); - }, + canSave ? saveScratch : forkCurrentScratch, ); const compileShortcut = useShortcut([SpecialKey.CTRL_COMMAND, "J"], () => { @@ -227,35 +237,23 @@ function Actions({
  • + {canSave && ( +
  • + : } + /> +
  • + )}
  • { - setIsSaving(true); - await fuzzySaveScratch(); - setIsSaving(false); - saveCallback(); - }} - disabled={!canSave || isSaving} - title={fuzzyShortcut} - text={"Save"} - icon={} - /> -
  • -
  • - { - setIsForking(true); - await forkScratch(); - setIsForking(false); - saveCallback(); - }} + onClick={forkCurrentScratch} disabled={isForking} - title={ - fuzzySaveAction === FuzzySaveAction.FORK - ? fuzzyShortcut - : undefined - } - text="Fork" + title={!canSave ? fuzzyShortcut : undefined} + text={!canSave ? "Fork to save" : "Fork"} icon={} />
  • diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 702862718..7d1e59ff7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -142,7 +142,12 @@ export async function claimScratch(scratch: ClaimableScratch): Promise { export async function forkScratch(parent: TerseScratch): Promise { const scratch = await post(`${scratchUrl(parent)}/fork`, parent); - await claimScratch(scratch); + + if (scratch.owner) { + await mutate("/user", scratch.owner, { revalidate: false }); + } + await mutate(scratchUrl(scratch), scratch, { revalidate: false }); + return scratch; } From de627f813a9c38d3be7bc9579c36a20fa303315b Mon Sep 17 00:00:00 2001 From: Mark Street Date: Fri, 15 May 2026 20:29:21 +0100 Subject: [PATCH 4/5] undo local testing --- frontend/next.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/next.config.js b/frontend/next.config.js index 2f7de1f3f..27887dde9 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -114,8 +114,6 @@ let app = { NEXT_PUBLIC_COMMIT_HASH: process.env.GIT_HASH ?? "abc123", OBJDIFF_BASE: process.env.OBJDIFF_BASE, }, - allowedDevOrigins: ['carbon'], - }; if (process.env.ANALYZE === "true") { From b81546ada352fb865e6c4c2b3f7567c239db9865 Mon Sep 17 00:00:00 2001 From: Mark Street Date: Fri, 15 May 2026 21:30:25 +0100 Subject: [PATCH 5/5] Better handling of scratch deletion --- .../src/app/scratch/[slug]/ScratchEditor.tsx | 75 +++++++++++++++---- frontend/src/components/Scratch/Scratch.tsx | 3 + .../src/components/Scratch/ScratchToolbar.tsx | 31 ++++---- .../Scratch/hooks/useFuzzySaveCallback.ts | 39 ---------- .../hooks/useWarnBeforeScratchUnload.ts | 9 ++- frontend/src/lib/api.ts | 16 ++-- 6 files changed, 96 insertions(+), 77 deletions(-) delete mode 100644 frontend/src/components/Scratch/hooks/useFuzzySaveCallback.ts diff --git a/frontend/src/app/scratch/[slug]/ScratchEditor.tsx b/frontend/src/app/scratch/[slug]/ScratchEditor.tsx index 6bda1a0f0..5805d52d3 100644 --- a/frontend/src/app/scratch/[slug]/ScratchEditor.tsx +++ b/frontend/src/app/scratch/[slug]/ScratchEditor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR, { type Middleware, SWRConfig } from "swr"; @@ -9,6 +9,7 @@ import useWarnBeforeScratchUnload from "@/components/Scratch/hooks/useWarnBefore import SetPageTitle from "@/components/SetPageTitle"; import * as api from "@/lib/api"; import { scratchUrl } from "@/lib/api/urls"; +import { ignoreNextWarnBeforeUnload } from "@/lib/hooks"; function ScratchPageTitle({ scratch }: { scratch: api.Scratch }) { const isSaved = api.useIsScratchSaved(scratch); @@ -26,10 +27,12 @@ function ScratchEditorInner({ offline, }: Props) { const [scratch, setScratch] = useState(initialScratch); + const [isDeleting, setIsDeleting] = useState(false); + const isDeletingRef = useRef(false); const currentScratchUrl = scratchUrl(scratch); const initialScratchUrl = scratchUrl(initialScratch); - useWarnBeforeScratchUnload(scratch); + useWarnBeforeScratchUnload(scratch, !isDeleting); // If the static props scratch changes (i.e. router push / page redirect), reset `scratch`. useEffect(() => { @@ -46,7 +49,7 @@ function ScratchEditorInner({ // 4. Notice the scratch owner (in the About panel) has changed to your newly-logged-in user const ownerMayChange = !scratch.owner || scratch.owner.is_anonymous; const cached = useSWR( - ownerMayChange && currentScratchUrl, + ownerMayChange && !isDeleting && currentScratchUrl, api.get, )?.data; useEffect(() => { @@ -66,29 +69,72 @@ function ScratchEditorInner({ // was updated, so the originally-loaded initialScratch prop becomes stale. // https://github.com/decompme/decomp.me/issues/711 useEffect(() => { + if (isDeleting) return; + let isCurrent = true; - api.get(initialScratchUrl).then((updatedScratch: api.Scratch) => { - if (!isCurrent) return; + api.get(initialScratchUrl) + .then((updatedScratch: api.Scratch) => { + if (!isCurrent) return; + + const updateTime = new Date(updatedScratch.last_updated); - const updateTime = new Date(updatedScratch.last_updated); + setScratch((scratch) => { + const scratchTime = new Date(scratch.last_updated); - setScratch((scratch) => { - const scratchTime = new Date(scratch.last_updated); + if (scratchTime < updateTime) { + console.info( + "Client got updated scratch", + updatedScratch, + ); + return updatedScratch; + } - if (scratchTime < updateTime) { - console.info("Client got updated scratch", updatedScratch); - return updatedScratch; + return scratch; + }); + }) + .catch((error) => { + if (!isCurrent) return; + + if ( + error instanceof api.ResponseError && + error.status === 404 && + isDeletingRef.current + ) { + return; } - return scratch; + throw error; }); - }); return () => { isCurrent = false; }; - }, [initialScratchUrl]); + }, [initialScratchUrl, isDeleting]); + + const deleteScratch = useCallback(async () => { + isDeletingRef.current = true; + setIsDeleting(true); + + try { + await api.delete_(currentScratchUrl, {}); + } catch (error) { + isDeletingRef.current = false; + setIsDeleting(false); + throw error; + } + + ignoreNextWarnBeforeUnload(); + window.location.href = scratch.project ? `/${scratch.project}` : "/"; + }, [currentScratchUrl, scratch.project]); + + if (isDeleting) { + return ( +
    +
    Deleting scratch...
    +
    + ); + } return ( <> @@ -103,6 +149,7 @@ function ScratchEditorInner({ return { ...scratch, ...partial }; }); }} + deleteScratch={deleteScratch} offline={offline} /> diff --git a/frontend/src/components/Scratch/Scratch.tsx b/frontend/src/components/Scratch/Scratch.tsx index a8c505868..fa4768fbb 100644 --- a/frontend/src/components/Scratch/Scratch.tsx +++ b/frontend/src/components/Scratch/Scratch.tsx @@ -180,6 +180,7 @@ function applyDefaultDiffTab( export type Props = { scratch: Readonly; onChange: (scratch: Partial) => void; + deleteScratch: () => Promise; parentScratch?: api.Scratch; initialCompilation?: Readonly; offline: boolean; @@ -188,6 +189,7 @@ export type Props = { export default function Scratch({ scratch, onChange, + deleteScratch, parentScratch, initialCompilation, offline, @@ -564,6 +566,7 @@ export default function Scratch({ scratch={scratch} setScratch={setScratch} saveCallback={saveCallback} + deleteScratch={deleteScratch} setDecompilationTabEnabled={setDecompilationTabEnabled} /> {matchProgressBarEnabledSetting && ( diff --git a/frontend/src/components/Scratch/ScratchToolbar.tsx b/frontend/src/components/Scratch/ScratchToolbar.tsx index 3770a3666..c5f9050e7 100644 --- a/frontend/src/components/Scratch/ScratchToolbar.tsx +++ b/frontend/src/components/Scratch/ScratchToolbar.tsx @@ -33,7 +33,6 @@ import PlatformLink from "../PlatformLink"; import { SpecialKey, useShortcut } from "../Shortcut"; import UserAvatar from "../user/UserAvatar"; -import useFuzzySaveCallback from "./hooks/useFuzzySaveCallback"; import styles from "./ScratchToolbar.module.scss"; const ACTIVE_MS = 1000 * 60; @@ -51,12 +50,6 @@ function exportScratchZip(scratch: api.Scratch) { a.click(); } -async function deleteScratch(scratch: api.Scratch) { - await api.delete_(scratchUrl(scratch), {}); - - window.location.href = scratch.project ? `/${scratch.project}` : "/"; -} - function EditTimeAgo({ date }: { date: string }) { const isActive = Date.now() - new Date(date).getTime() < ACTIVE_MS; @@ -190,11 +183,12 @@ function Actions({ scratch, setScratch, saveCallback, + deleteScratch, setDecompilationTabEnabled, }: Props) { const userIsYou = api.useUserIsYou(); const forkScratch = api.useForkScratchAndGo(scratch); - const [, fuzzySaveScratch] = useFuzzySaveCallback(scratch, setScratch); + const saveScratchRequest = api.useSaveScratch(scratch); const [isSaving, setIsSaving] = useState(false); const [isForking, setIsForking] = useState(false); @@ -207,18 +201,24 @@ function Actions({ if (!canSave || isSaved || isSaving) return; setIsSaving(true); - await fuzzySaveScratch(); - setIsSaving(false); - saveCallback(); + try { + setScratch(await saveScratchRequest()); + saveCallback(); + } finally { + setIsSaving(false); + } }; const forkCurrentScratch = async () => { if (isForking) return; setIsForking(true); - await forkScratch(); - setIsForking(false); - saveCallback(); + try { + await forkScratch(); + saveCallback(); + } finally { + setIsForking(false); + } }; const fuzzyShortcut = useShortcut( @@ -267,7 +267,7 @@ function Actions({ "Are you sure you want to delete this scratch? This action cannot be undone.", ) ) { - deleteScratch(scratch); + void deleteScratch(); } }} text="Delete" @@ -341,6 +341,7 @@ export type Props = { scratch: Readonly; setScratch: (scratch: Partial) => void; saveCallback: () => void; + deleteScratch: () => Promise; setDecompilationTabEnabled: (enabled: boolean) => void; }; diff --git a/frontend/src/components/Scratch/hooks/useFuzzySaveCallback.ts b/frontend/src/components/Scratch/hooks/useFuzzySaveCallback.ts deleted file mode 100644 index 26ba1de22..000000000 --- a/frontend/src/components/Scratch/hooks/useFuzzySaveCallback.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useCallback } from "react"; - -import * as api from "@/lib/api"; - -export enum FuzzySaveAction { - SAVE = 0, - FORK = 1, - NONE = 2, -} - -export default function useFuzzySaveCallback( - scratch: api.Scratch, - setScratch: (partial: Partial) => void, -): [FuzzySaveAction, () => Promise] { - const saveScratch = api.useSaveScratch(scratch); - const forkScratch = api.useForkScratchAndGo(scratch); - const userIsYou = api.useUserIsYou(); - - let action = FuzzySaveAction.NONE; - if (userIsYou(scratch.owner)) { - action = FuzzySaveAction.SAVE; - } else { - action = FuzzySaveAction.FORK; - } - - return [ - action, - useCallback(async () => { - switch (action) { - case FuzzySaveAction.SAVE: - setScratch(await saveScratch()); - break; - case FuzzySaveAction.FORK: - await forkScratch(); - break; - } - }, [action, forkScratch, saveScratch, setScratch]), - ]; -} diff --git a/frontend/src/components/Scratch/hooks/useWarnBeforeScratchUnload.ts b/frontend/src/components/Scratch/hooks/useWarnBeforeScratchUnload.ts index 243596403..f25bc9d1d 100644 --- a/frontend/src/components/Scratch/hooks/useWarnBeforeScratchUnload.ts +++ b/frontend/src/components/Scratch/hooks/useWarnBeforeScratchUnload.ts @@ -1,12 +1,15 @@ import * as api from "@/lib/api"; import { useWarnBeforeUnload } from "@/lib/hooks"; -export default function useWarnBeforeScratchUnload(scratch: api.Scratch) { +export default function useWarnBeforeScratchUnload( + scratch: api.Scratch, + enabled = true, +) { const userIsYou = api.useUserIsYou(); - const isSaved = api.useIsScratchSaved(scratch); + const isSaved = api.useIsScratchSaved(scratch, enabled); useWarnBeforeUnload( - !isSaved, + enabled && !isSaved, userIsYou(scratch.owner) ? "You have not saved your changes to this scratch. Discard changes?" : "You have edited this scratch but not saved it in a fork. Discard changes?", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7d1e59ff7..c1aaaf288 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -86,10 +86,14 @@ export function useUserIsYou(): ( ); // eslint-disable-line react-hooks/exhaustive-deps } -export function useSavedScratch(scratch: Scratch): Scratch { - const { data: savedScratch, error } = useSWR(scratchUrl(scratch), get, { - fallbackData: scratch, // No loading state, just use the local scratch - }); +export function useSavedScratch(scratch: Scratch, enabled = true): Scratch { + const { data: savedScratch, error } = useSWR( + enabled ? scratchUrl(scratch) : null, + get, + { + fallbackData: scratch, // No loading state, just use the local scratch + }, + ); if (error) throw error; @@ -162,8 +166,8 @@ export function useForkScratchAndGo(parent: TerseScratch): () => Promise { }, [parent, router]); } -export function useIsScratchSaved(scratch: Scratch): boolean { - const saved = useSavedScratch(scratch); +export function useIsScratchSaved(scratch: Scratch, enabled = true): boolean { + const saved = useSavedScratch(scratch, enabled); return isScratchSaved(scratch, saved); }