diff --git a/CLAUDE.md b/CLAUDE.md index 993aaa52..f5c47db0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ Opinionated by design: the behaviour every Deco TanStack site should have is fix | `@decocms/tanstack/server` | `src/server/index.ts` — `contentClient(cms, request?)` (`cms.client({ version, visitor, experiments, campaigns })` from the worker's `x-deco-version`, `x-deco-visitor` and `x-deco-assignments`: the page decides nothing again), `requestVersion`, `requestVisitor`, `requestAssignments`, `markPrivate`, `isServerFnCall`. Server only | | `@decocms/tanstack/start` | `src/start/index.ts` — `decoServerFnFetch` for `createStart({ serverFns: { fetch } })`. Browser-safe | | `@decocms/tanstack/router` | `src/router/index.ts` — `createDecoRouter`, `decoParseSearch`/`decoStringifySearch` (keep `?q=`, v7 #646), `PAGE_STATUS_HEADER`. Isomorphic | -| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts the "." shortcut (`src/studioShortcut.ts`, the same file as `@decocms/nextjs`'s; v8 only, no v7 framed handler, Cypress-safe: `tests/studio-shortcut.test.ts`), `StudioChannel` (its `studioOrigins` prop, else `studioOrigins()` of `src/studio.ts`: `DECO_STUDIO_ORIGIN` comma-separated, else `DEFAULT_STUDIO_ORIGINS`), `StudioRefresh` (`src/react/StudioRefresh.ts`: sets `window[STUDIO_PAGE_REFRESH]`, which shows a save with `router.invalidate()`; a moved draft pointer is first fetched as a document for its cookie, which the loaders' server functions carry) and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe | +| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts the "." shortcut (`src/studioShortcut.ts`, the same file as `@decocms/nextjs`'s; v8 only, no v7 framed handler, Cypress-safe: `tests/studio-shortcut.test.ts`), `StudioChannel` (its `studioOrigins` prop, else `studioOrigins()` of `src/studio.ts`: `DECO_STUDIO_ORIGIN` comma-separated, else `DEFAULT_STUDIO_ORIGINS`), `StudioRefresh` (`src/react/StudioRefresh.ts`: sets `window[STUDIO_PAGE_REFRESH]`, which shows a save with `router.invalidate()`, keeping the page's scroll; a moved draft pointer is first fetched as a document for its cookie, which the loaders' server functions carry) and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe | | `@decocms/tanstack/vite` | `src/vite/index.ts` — `decoTanstack({ buildHash })` (= `decoContent()` from `@decocms/blocks/vite`, `__BUILD_HASH__`, a browser stub for `node:async_hooks`, `resolve.dedupe`), `gitBuildHash` | | `@decocms/tanstack/scope` | `src/scope/server.ts` / `browser.ts` — the request scope the worker hands the render (`AsyncLocalStorage`). Conditional export: `workerd` and `node` before `browser`, because a Workers build turns `browser` on too; `src/__tests__/exports.test.ts` resolves it under those conditions | diff --git a/packages/tanstack/src/react/StudioRefresh.test.tsx b/packages/tanstack/src/react/StudioRefresh.test.tsx index 5c456e50..8506a409 100644 --- a/packages/tanstack/src/react/StudioRefresh.test.tsx +++ b/packages/tanstack/src/react/StudioRefresh.test.tsx @@ -7,6 +7,9 @@ const calls = vi.hoisted(() => [] as string[]); type Match = { id: string; updatedAt: number }; const router = vi.hoisted(() => { const r = { + options: { scrollRestoration: true as boolean | (() => boolean) }, + /** What `scrollRestoration` answered while the loaders ran. */ + restorationDuringLoad: [] as unknown[], state: { matches: [] as Match[] }, /** What a navigation loads: the ids it gives a fresh `updatedAt`. */ reloads: [] as string[], @@ -18,6 +21,8 @@ const router = vi.hoisted(() => { ); }), invalidate: vi.fn(async (opts?: { filter?: (m: Match) => boolean }) => { + const restoration = r.options.scrollRestoration; + r.restorationDuringLoad.push(typeof restoration === "function" ? restoration() : restoration); const ids = opts?.filter ? r.state.matches.filter(opts.filter).map((m) => m.id) : ["*"]; calls.push(`invalidate ${ids.join(",")}`); }), @@ -45,6 +50,8 @@ beforeEach(async () => { { id: "/shoes", updatedAt: 0 }, ]; router.reloads = []; + router.options.scrollRestoration = true; + router.restorationDuringLoad = []; globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { calls.push(`fetch ${String(input)}`); return new Response(""); @@ -92,6 +99,23 @@ describe("StudioRefresh", () => { expect(calls.at(-1)).toBe("invalidate __root__"); }); + it("keeps the page's scroll: restoration is off while it loads, then the site's own again", async () => { + await slot()!(window.location.href); + expect(router.restorationDuringLoad).toEqual([false]); + expect(router.options.scrollRestoration).toBe(true); + + router.reloads = ["/shoes"]; + await slot()!(`${window.location.origin}/shoes?__draft=b`); + expect(router.restorationDuringLoad).toEqual([false, false]); + expect(router.options.scrollRestoration).toBe(true); + }); + + it("gives the site's scroll restoration back after a failed refresh too", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 500 })) as typeof fetch; + await expect(slot()!(`${window.location.origin}/shoes?__draft=b`)).rejects.toThrow(/500/); + expect(router.options.scrollRestoration).toBe(true); + }); + it("rejects when the new link fails, leaving the page as it is", async () => { globalThis.fetch = vi.fn(async () => new Response("", { status: 500 })) as typeof fetch; await expect(slot()!(`${window.location.origin}/shoes?__draft=b`)).rejects.toThrow(/500/); diff --git a/packages/tanstack/src/react/StudioRefresh.ts b/packages/tanstack/src/react/StudioRefresh.ts index 5c9864ee..d3d9b163 100644 --- a/packages/tanstack/src/react/StudioRefresh.ts +++ b/packages/tanstack/src/react/StudioRefresh.ts @@ -13,6 +13,13 @@ * loads again on that move; only the matches it didn't load are invalidated, * so the page renders the save once. * + * The page keeps its scroll position. After every load the router restores + * the position saved for the URL or scrolls to the top, and `invalidate()` + * takes no `resetScroll`, so an editor working on the footer was sent back + * up on every save. While a refresh runs, `scrollRestoration` is a function + * that answers `false`, which the router reads as "leave the scroll alone"; + * the site's own setting is back two frames after the last render. + * * Set on every page, framed or not: Studio's module is the only caller, and * comparing the window with its parent here is what a test runner's * frame-busting rewrite matches (`tests/studio-shortcut.test.ts`). @@ -22,12 +29,35 @@ import { useEffect } from "react"; import { STUDIO_PAGE_REFRESH } from "../studio.ts"; type StudioPageRefresh = (url: string) => Promise; +type Router = ReturnType; + +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); + +/** Runs `work` with the router's scroll restoration off; overlapping calls share one override. */ +function keepScroll(router: Router) { + let depth = 0; + let saved: Router["options"]["scrollRestoration"]; + return async (work: () => Promise): Promise => { + if (depth++ === 0) { + saved = router.options.scrollRestoration; + router.options.scrollRestoration = () => false; + } + try { + return await work(); + } finally { + await nextFrame(); + await nextFrame(); + if (--depth === 0) router.options.scrollRestoration = saved; + } + }; +} export function StudioRefresh(): null { const router = useRouter(); useEffect(() => { - const refresh: StudioPageRefresh = async (url) => { + const inPlace = keepScroll(router); + const show = async (url: string) => { const target = new URL(url, location.href); if (target.origin !== location.origin) { throw new Error(`Not this page's origin: ${target.origin}`); @@ -55,6 +85,7 @@ export function StudioRefresh(): null { const ids = new Set(stale.map((m) => m.id)); await router.invalidate({ filter: (m) => ids.has(m.id) }); }; + const refresh: StudioPageRefresh = (url) => inPlace(() => show(url)); const slot = window as unknown as Record; slot[STUDIO_PAGE_REFRESH] = refresh; return () => {