Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
24 changes: 24 additions & 0 deletions packages/tanstack/src/react/StudioRefresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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(",")}`);
}),
Expand Down Expand Up @@ -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("<html></html>");
Expand Down Expand Up @@ -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/);
Expand Down
33 changes: 32 additions & 1 deletion packages/tanstack/src/react/StudioRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand All @@ -22,12 +29,35 @@ import { useEffect } from "react";
import { STUDIO_PAGE_REFRESH } from "../studio.ts";

type StudioPageRefresh = (url: string) => Promise<void>;
type Router = ReturnType<typeof useRouter>;

const nextFrame = () => new Promise<void>((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 <T>(work: () => Promise<T>): Promise<T> => {
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}`);
Expand Down Expand Up @@ -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<string, unknown>;
slot[STUDIO_PAGE_REFRESH] = refresh;
return () => {
Expand Down
Loading