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
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Each export is built from the source file below (`dist/<same path>.js`, see abov
| `@decocms/blocks/metrics` | `src/v8/metrics.ts` — `recordServerRequest`, for the framework bindings (`createDecoWorker`, `@decocms/nextjs/instrumentation`): `http.server.request.duration` on the CMS's telemetry; sites don't import it. Resolves without calling the route function with telemetry off; never rejects |
| `@decocms/blocks/preview` | `src/v8/preview/index.ts` — `DraftBadge` (the "Preview mode" badge: a `"use client"` component, renders nothing without a pointer or inside a frame) and `StudioBridge` (a hook-free inline `<script>`: answers v7's `scrollToComponent` and `editor::rerender` (same-origin navigation) only when framed and only for the `trustedOrigins` the binding passes in, and runs no script Studio sends; unframed, v7's "." edit shortcut is on unless `editShortcut={false}`, and sends the page's `pathTemplate` from its `page` prop or, winning, the last `StudioPage` marker the page rendered, read at the keypress); `StudioChannel` (`StudioChannel.ts`, `channel.test.tsx`; protocol `src/protocol/channel.ts`): the Studio channel: every page, production included, carries `StudioChannel` (a ~1.4 kB inline script, ~0.7 kB gzipped). It trusts the list of exact origins the binding passes (`studioOrigins`, `data-studio-origins`; no wildcard). Framed (`window.parent !== window`), it posts `{ type: "deco:ready" }` to the parent with each trusted origin as `targetOrigin` (the browser delivers it only to a parent at one of them; where `location.ancestorOrigins` names the parent, to that origin alone, if trusted); on `{ type: "deco:load", url }` from the parent whose `event.origin` is exactly one of them, with `url` on exactly the origin of that message, it `import()`s the module once per URL and calls its default export with `{ studioOrigin, url, send, on }` bound to that origin, answering `deco:loaded` or `deco:load-error` to it (a failed URL may be retried); every later message from that origin whose `type` doesn't start with `deco:` goes to the listeners of the modules it loaded. Not framed, framed by another origin, or any failed check: nothing. It is the only way Studio runs code in the page (the bridge has no `editor::inject`). No framework code, no Studio origin list and no environment read: the binding (`@decocms/tanstack`, `@decocms/nextjs`) owns Studio's origins (`studioOrigins()`: its option, else `DECO_STUDIO_ORIGIN` comma-separated, else production and staging) and adds the same list to the page's CSP `script-src` and `frame-ancestors` (`withStudioCsp`) |
| `@decocms/blocks/cli` | `src/v8/cli/index.ts` — the `deco` CLI (`schema`, `content`, `check`, the remote commands, `skills install`), also the bin (`bin/deco.js`, which runs the compiled `dist/v8/cli/main.js` under Node or Bun). CLI-only: the runtime never imports it (`run.test.ts`), so it and the TypeScript compiler never reach an app bundle |
| `@decocms/blocks/protocol` (+ `/keys`, `/server`, `/storage/fs`, `/conformance`) | `src/protocol/**` — the content protocol the site editor uses, plus the Studio channel's message and module types for Studio (`CHANNEL_MESSAGE`, `StudioChannelContext`, `StudioChannelModule`, `defineStudioModule`, `DEFAULT_STUDIO_ORIGIN`, `DEFAULT_STUDIO_ORIGINS`, `src/protocol/channel.ts`); browser-safe except `storage/fs` (conditional export: `node` → real, `default` → a stub that throws) |
| `@decocms/blocks/protocol` (+ `/keys`, `/server`, `/storage/fs`, `/conformance`) | `src/protocol/**` — the content protocol the site editor uses, plus the Studio channel's message and module types for Studio (`CHANNEL_MESSAGE`, `StudioChannelContext`, `StudioChannelModule`, `defineStudioModule`, `DEFAULT_STUDIO_ORIGIN`, `DEFAULT_STUDIO_ORIGINS`, and `STUDIO_PAGE_REFRESH`/`StudioPageRefresh`: the `window` hook a binding sets so Studio's HTML swap re-renders the page through its router instead of replacing the document; `src/protocol/channel.ts`); browser-safe except `storage/fs` (conditional export: `node` → real, `default` → a stub that throws) |

### `@decocms/tanstack` exports

Expand All @@ -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`) 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()`; 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 All @@ -117,7 +117,7 @@ Small helpers for any Next.js App Router app, with no hosting assumptions (no CD
|---|---|
| `@decocms/nextjs/proxy` | `src/proxy/index.ts` — `decideDraft(cms, request)` (the CMS's preview hosts, `cms.readVersion`, `src/drafts.ts`), `draftRequestHeaders` (`x-deco-version`; forged `x-deco-version`/`x-deco-visitor`/`x-deco-assignments` removed), `rewriteToDraftRoute` (`/_draft`, with the visitor and the assignments when given), `applyDraft` (cookie, `draftResponseHeaders`), `isDraftRoutePath`, `decideVisitor` (`readVisitor`; the cookie on a first visit only, never from a preview; reads no content), `visitorRequestHeaders` (`x-deco-visitor`), `decideAssignments` (every test and campaign, decided once: `cms.client({ visitor, ...version })`), `assignmentsRequestHeaders` (`x-deco-assignments`), `applyVisitor`, the assignments route (`rewriteToAssignmentsRoute`, `assignmentsSegment`: every name with its state), `draftProxy` (all of it). `DraftDecision.version` is `cms.readVersion`'s shape (blocks' `parseVersion` after the binding's host check). No React, no `next/headers`, no block map (`src/__tests__/exports.test.ts`) |
| `@decocms/nextjs/server` | `src/server/index.ts` — `bindDraft(cms, { searchParams })` (param, then `x-deco-version`, then cookie; the same host check), `activeDraftPointer`, `contentClient(cms)` (`cms.client({ version, experiments, campaigns })` with what the proxy decided), `assignedClient(cms, segment)` (async: the assignments route's fully decided client, a test at 0% kept off); `bindDraft` also binds the proxy's `x-deco-visitor` and `x-deco-assignments` (for a preview always; on the current release only when it saves an `experiments` or `campaigns` block, so a static page stays static, and then a test at 0% stays off whatever the header says). The draft lives in React's `cache()` (`server/slot.ts`), so no conditional export is needed |
| `@decocms/nextjs` | `src/index.ts` — `DraftPreviewIndicator` (renders `DraftBadge` for the bound draft), `DraftBadge`, `StudioBridge` (`src/StudioBridge.ts`, v8 only: the "." shortcut, `src/studioShortcut.ts` (no v7 framed handler, so nothing a Cypress frame-busting rewrite matches: `tests/studio-shortcut.test.ts`), followed by blocks' `StudioChannel` for `studioOrigins(props.studioOrigins)`), `withStudioCsp` (`src/csp.ts`: the site's own CSP with Studio's origins in `script-src` and `frame-ancestors`; the binding sends no CSP itself), and the package's own draft names and Studio origins: `DRAFT_PARAM`/`DRAFT_COOKIE`/`VERSION_HEADER`, `draftResponseHeaders` (`src/draft.ts`), `VISITOR_HEADER` (`src/visitor.ts`), `ASSIGNMENTS_HEADER` (`src/assignments.ts`), `STUDIO_ORIGINS`/`STUDIO_FRAME_ANCESTORS`/`isStudioOrigin`/`STUDIO_ORIGIN`/`DEFAULT_STUDIO_ORIGINS`/`studioOrigins` (`src/studio.ts`). The same values as `@decocms/tanstack`'s, never imported from it (`exports.test.ts`) |
| `@decocms/nextjs` | `src/index.ts` — `DraftPreviewIndicator` (renders `DraftBadge` for the bound draft), `DraftBadge`, `StudioBridge` (`src/StudioBridge.ts`, v8 only: the "." shortcut, `src/studioShortcut.ts` (no v7 framed handler, so nothing a Cypress frame-busting rewrite matches: `tests/studio-shortcut.test.ts`), followed by blocks' `StudioChannel` for `studioOrigins(props.studioOrigins)`, then `StudioRefresh` (`src/StudioRefresh.tsx`, `"use client"`: sets `window[STUDIO_PAGE_REFRESH]`, which shows a save with `router.refresh()`, or `router.replace()` for a moved draft pointer, so React stays attached)), `withStudioCsp` (`src/csp.ts`: the site's own CSP with Studio's origins in `script-src` and `frame-ancestors`; the binding sends no CSP itself), and the package's own draft names and Studio origins: `DRAFT_PARAM`/`DRAFT_COOKIE`/`VERSION_HEADER`, `draftResponseHeaders` (`src/draft.ts`), `VISITOR_HEADER` (`src/visitor.ts`), `ASSIGNMENTS_HEADER` (`src/assignments.ts`), `STUDIO_ORIGINS`/`STUDIO_FRAME_ANCESTORS`/`isStudioOrigin`/`STUDIO_ORIGIN`/`DEFAULT_STUDIO_ORIGINS`/`studioOrigins` (`src/studio.ts`). The same values as `@decocms/tanstack`'s, never imported from it (`exports.test.ts`) |
| `@decocms/nextjs/config` | `src/config/index.cjs` (+ `index.d.cts`), plain CommonJS shipped from `src` — `withDeco(nextConfig, { draftHeaders, studioOrigins })`: v7's draft header rules first, `STUDIO_ORIGINS` in `allowedDevOrigins`; no rewrites, no image rules. It repeats the package's names (`src/draft.ts`, `src/studio.ts`), held equal by `config.test.ts` |
| `@decocms/nextjs/instrumentation` | `src/instrumentation/index.ts` — `register()` (the site's `instrumentation.ts`: a minimal global tracer provider whose only job is Next's root `BaseServer.handleRequest` span, recorded with `recordServerRequest` from `@decocms/blocks/metrics`; exports no span, non-recording for every other span; skips the proxy's own bubbled `handleRequest`; no-op on Edge, without `@opentelemetry/api` (the app's, else Next's compiled copy) or when another provider is registered) and `serverRequestSpanProcessor()` for a site with its own OpenTelemetry SDK. `@opentelemetry/api` is an optional peer |
| `@decocms/nextjs/image-loader` | `src/image-loader/index.ts` — `createDecoImageLoader(imageUrl, { fallback })`, `nextImageOptimizer`. Platform-neutral: the site composes its platform's helpers (`@decocms/apps-vtex/image`, …) into the `ImageUrl`; the package depends on no `@decocms/apps-*`. `tests/nextjs-image-loader.golden.test.ts` runs the next/image golden URLs of blocks and apps-vtex through that composition (no changed case but the PO-approved fix-2 ones, marked `before`/`reason` in the fixture) |
Expand Down
18 changes: 18 additions & 0 deletions packages/blocks/src/protocol/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ export type StudioChannelModule<
Out extends ModuleMessage = ModuleMessage,
> = (context: StudioChannelContext<In, Out>) => void | Promise<void>;

/**
* The `window` property a binding sets when its framework can show a save by
* re-rendering the page in place (Next.js: `router.refresh()`): a
* {@link StudioPageRefresh}. Studio's HTML swap calls it instead of replacing
* the document, whose new scripts never run, so the page's client components
* would stop at their server markup (a footer revealed on scroll, a carousel).
* A page without it gets the document swap.
*/
export const STUDIO_PAGE_REFRESH = "__decoStudioRefresh";

/**
* Shows `url` (absolute, on the page's own origin: the page's URL, or a new one
* whose draft pointer moved) from the server without reloading, keeping the
* page's client state; resolves once the new content is on screen, rejects
* when it can't show it (Studio then reloads the frame).
*/
export type StudioPageRefresh = (url: string) => Promise<void>;

/** Types a module's default export: `export default defineStudioModule<In, Out>((channel) => …)`. */
export function defineStudioModule<
In extends ModuleMessage = ModuleMessage,
Expand Down
2 changes: 2 additions & 0 deletions packages/blocks/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ export {
defineStudioModule,
type ModuleMessage,
type PageChannelMessage,
STUDIO_PAGE_REFRESH,
type StudioChannelContext,
type StudioChannelMessage,
type StudioChannelModule,
type StudioPageRefresh,
} from "./channel.ts";
export {
assertSupportedEndpoint,
Expand Down
6 changes: 6 additions & 0 deletions packages/nextjs/src/StudioBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@
* Components payload that carries the script). Two inline scripts and no
* hooks, so a Server Component renders it as is. The site's CSP allows the
* same origins with `withStudioCsp`.
*
* After a save, Studio shows the new content through the App Router
* (`StudioRefresh`, a client component, so React stays attached to the page)
* instead of replacing the document.
*/
import { StudioChannel, type StudioPageInfo } from "@decocms/blocks/preview";
import { createElement, Fragment, type ReactNode } from "react";
import { StudioRefresh } from "./StudioRefresh.tsx";
import { studioOrigins } from "./studio.ts";
import { StudioShortcut } from "./studioShortcut.ts";

Expand Down Expand Up @@ -64,5 +69,6 @@ export function StudioBridge({
null,
createElement(StudioShortcut, { site, editShortcut, page, nonce }),
createElement(StudioChannel, { studioOrigins: studioOrigins(origins), nonce }),
createElement(StudioRefresh),
);
}
72 changes: 72 additions & 0 deletions packages/nextjs/src/StudioRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const router = vi.hoisted(() => ({ refresh: vi.fn(), replace: vi.fn() }));
vi.mock("next/navigation.js", () => ({ useRouter: () => router }));

const { StudioRefresh } = await import("./StudioRefresh.tsx");
const { STUDIO_PAGE_REFRESH } = await import("./studio.ts");

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

type Refresh = (url: string) => Promise<void>;
const slot = () => (window as unknown as Record<string, Refresh | undefined>)[STUDIO_PAGE_REFRESH];

let root: Root;
beforeEach(async () => {
router.refresh.mockReset();
router.replace.mockReset();
window.history.replaceState(null, "", "/shoes?__draft=a");
root = createRoot(document.body.appendChild(document.createElement("div")));
await act(async () => root.render(<StudioRefresh />));
});
afterEach(async () => {
await act(async () => root.unmount());
document.body.innerHTML = "";
});

describe("StudioRefresh", () => {
it("puts the page's refresh where Studio's HTML swap looks for it", () => {
expect(typeof slot()).toBe("function");
});

it("refreshes the page's own URL in place, and resolves once the transition is over", async () => {
let done = false;
// React flushes the transition when act's callback returns, so the
// promise is awaited after it.
let refreshed!: Promise<void>;
await act(async () => {
refreshed = slot()!(window.location.href).then(() => {
done = true;
});
});
await refreshed;
expect(done).toBe(true);
expect(router.refresh).toHaveBeenCalledTimes(1);
expect(router.replace).not.toHaveBeenCalled();
});

it("navigates to a new URL of the page (a moved draft pointer) without scrolling", async () => {
let refreshed!: Promise<void>;
await act(async () => {
refreshed = slot()!(`${window.location.origin}/shoes?__draft=b#top`);
});
await refreshed;
expect(router.replace).toHaveBeenCalledWith("/shoes?__draft=b#top", { scroll: false });
expect(router.refresh).not.toHaveBeenCalled();
});

it("refuses another origin's URL", async () => {
await expect(slot()!("https://elsewhere.example/shoes")).rejects.toThrow(/origin/);
expect(router.refresh).not.toHaveBeenCalled();
expect(router.replace).not.toHaveBeenCalled();
});

it("takes it away when it unmounts", async () => {
await act(async () => root.unmount());
expect(slot()).toBeUndefined();
root = createRoot(document.body.appendChild(document.createElement("div")));
});
});
Loading
Loading