Files
webstudio/apps/builder/app/shared/logout.client.tsx
Oleg IsonenandClaude Fable 5 de23e2a888 chore: migrate toolchain to oxc (oxfmt, oxlint, rolldown-vite) and typeScript 7 (#5868)
## prettier -> oxfmt (0.58.0)

- .oxfmtrc.json generated with `oxfmt --migrate=prettier`: trailingComma
es5, printWidth 80 (oxfmt defaults to 100), sortPackageJson disabled,
.prettierignore patterns folded in (file deleted).
- ignorePatterns additionally exclude yml/yaml/toml/jsonc and
packages/cli/templates/** so oxfmt covers exactly the file set prettier
used to format (ts,tsx,js,json,css,md) and never touches user-facing
scaffolding artifacts.
- root `format` script, nano-staged pre-commit hook and the CI check
(`pnpm oxfmt --check`, replacing `prettier --cache --check` and its
actions/cache step — oxfmt checks the whole repo in ~0.7s) now run
oxfmt.
- every generator script that ran `prettier --write` on __generated__
output now runs `oxfmt` (css-data, icons, html-data, postgrest, sdk,
sdk-components-*, apps/builder tailwind preflight).
- the four scripts that used prettier's JS `format()` API write raw
source instead and their package.json scripts pipe the generated file
through the oxfmt CLI: project-build generate-docs +
generate-runtime-operation-contracts, cli generate-docs, apps/builder
generate-server-only-api-metadata. oxfmt rejects paths containing "..",
so cross-package generated files are formatted via a subshell cd into
the owning package.
- one-time reformat: oxfmt's canonical output differs from prettier on 8
repo source files (binary-operator continuation indent, import() call
wrapping, sub-pixel spacing of empty for-clauses); oxfmt honors existing
`// prettier-ignore` comments so intentional layouts are kept.
- prettier remains ONLY as a cli devDependency for the
prettier/standalone HTML normalizer inside html-to-jsx.test.ts, and
inside fixtures/ + packages/cli/templates/ (ships to scaffolded user
projects; out of scope here).
- editor config switched from esbenp.prettier-vscode to oxc.oxc-vscode
(devcontainer extension list, .vscode editor.defaultFormatter).

## oxlint 1.56 -> 1.73

- oxlint 1.73 rules-of-hooks special-cases hooks NAMED useEffectEvent:
their results may only be called from effects. Our
app/shared/hook-utils/effect-event.ts is a stable-callback helper
(useEvent pattern), intentionally called from event handlers and passed
as props. The rule is disabled via .oxlintrc.json overrides for the five
files doing this (product code untouched); note those files lose
hook-order linting entirely until the rule becomes configurable.

## TypeScript 5.8.2 -> 7.0.2 (native compiler)

- `tsc` is now the Go-based native binary; all `tsgo` script usages
(workspace packages and fixtures) renamed back to `tsc` and
@typescript/native-preview removed (the package is being phased out).
- fixes for TS7 changed defaults and stricter checks:
- `types` now defaults to [] -> base tsconfig sets "types": ["node"]
(restores the implicit-@types/node behavior of TS5).
- `rootDir` now defaults to ./ -> every tsconfig.dts.json sets
"rootDir": "./src" (TS5011 otherwise).
- import assertions removed -> cli/src/cli.ts uses `with { type: "json"
}` (TS2880).
- stricter isolatedDeclarations inference -> explicit annotations in
css-data bin/property-filter.ts (TS9010) and image image-dev.stories.tsx
(TS9013).
- declaration serialization limit -> project-build builder-runtime.ts
runtimeOperationContracts gets an explicit type (TS7056); the annotation
preserves the narrow RuntimeOperationId union.
- TS7 ships no stable programmatic API:
- react-sdk (component-generator tests drive the compiler API) aliases
its typescript devDependency to @typescript/typescript6@^6.0.2; its bin
is `tsc6`, so the package's typecheck still runs native TS7 tsc from the
workspace root.
- generate-arg-types keeps typescript 5.8.2 for react-docgen-typescript.
- fixtures/ and packages/cli/templates/ keep typescript 5.8.2
(user-facing); fixture scripts still work because plain `tsc` resolves
to their local dependency.

## vite -> rolldown-vite 7.3.1 (oxc bundler)

- global pnpm override `"vite": "npm:rolldown-vite@^7.3.1"`: the builder
app (remix v2 vite plugin works unmodified), every package vite build
and the storybook build now bundle on rolldown/oxc.
- vitest is scoped BACK to vanilla vite via `vitest>vite`,
`vite-node>vite` and `@vitest/mocker>vite` overrides because vitest
3.1's runner does not understand rolldown's SSR transform
(__vite_ssr_exportName__ ReferenceError in every suite).
- those overrides pin vite EXACTLY to 6.3.4: letting ^6.3.4 float to
6.4.3 breaks cli prebuild tests (dynamic import of a generated
app/routes/$.tsx fails to resolve under 6.4.3's loader).

## unrelated cleanup requested during review

- apps/builder commands.ts imports duplicate-tokens-group statically
instead of via dynamic import. The circular dependency the dynamic
import guarded against only exists through the command-panel barrel
(command-panel.tsx > groups/index.ts > commands-group.tsx >
commands.ts); importing the group module directly is cycle-free
(verified with madge), matching the neighboring wrap-group and
convert-group imports.

## verification

- pnpm lint, oxfmt --check: clean.
- pnpm -r typecheck (TS7 native) and pnpm dts: clean.
- pnpm -r test: all suites pass (builder 2093 tests, cli 630, react-sdk
45, project-build 11, ...).
- builder app build, full workspace build, cli build and storybook build
all succeed on rolldown-vite.

## Description

1. What is this PR about (link the issue and add a short description)

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 01:03:36 +02:00

162 lines
4.4 KiB
TypeScript

import { Text, Grid } from "@webstudio-is/design-system";
import { useEffect, useRef, useState } from "react";
import { useEffectEvent } from "./hook-utils/effect-event";
import { fetch } from "~/shared/fetch.client";
import { z } from "zod";
import { restLogoutPath } from "./router-utils";
export type LogoutProps = {
logoutUrls: string[];
onFinish: (failedProjects?: string[]) => void;
};
const MAX_RETRIES = 3;
const Logout = (props: LogoutProps) => {
const [logoutState, setLogoutState] = useState({
retries: MAX_RETRIES,
logoutUrls: props.logoutUrls,
});
useEffect(() => {
if (logoutState.retries === 0) {
if (logoutState.retries === 0) {
// Show error message
props.onFinish(logoutState.logoutUrls);
}
return;
}
Promise.allSettled(
logoutState.logoutUrls.map(async (url) => {
await new Promise((resolve) =>
setTimeout(resolve, (MAX_RETRIES - logoutState.retries) * 1000)
);
const response = await fetch(url, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
});
if (response.ok) {
return response;
}
console.error(`Logout failed for URL: ${url}`, response);
throw new Error(`Logout failed for URL: ${url}`);
})
).then((results) => {
const failedUrls: string[] = [];
results.forEach((result, index) => {
if (result.status === "rejected") {
failedUrls.push(logoutState.logoutUrls[index]);
}
});
if (failedUrls.length === 0) {
props.onFinish();
return;
}
setLogoutState({
retries: logoutState.retries - 1,
logoutUrls: failedUrls,
});
});
}, [logoutState, props]);
return (
<Grid
gap={2}
css={{
position: "fixed",
inset: 0,
alignContent: "center",
justifyItems: "center",
}}
>
<Text variant={"bigTitle"}>Logging out ...</Text>
</Grid>
);
};
export type LogoutPageProps = {
logoutUrls: string[];
};
const logoutResponse = z.object({
redirectTo: z.string(),
});
export const LogoutPage = (props: LogoutPageProps) => {
const refForm = useRef<HTMLFormElement>(null);
const handleLogout = useEffectEvent(async (formData: FormData) => {
const response = await fetch(restLogoutPath(), {
method: "POST",
body: JSON.stringify(Object.fromEntries(formData.entries())),
headers: { "content-type": "application/json" },
redirect: "manual",
});
if (false === response.ok) {
throw {
message: "Logout failed. Please try again later.",
description: `Logout request failed with status ${response.status}: ${await response.text()}`,
};
}
const data = await response.json();
const parsedData = logoutResponse.safeParse(data);
if (false === parsedData.success) {
throw {
message: "Logout failed. Please try again later",
description: "Logout request failed: Unsupported endpoint response",
};
}
if (formData.get("error") !== null) {
const value = formData.get("error")!.toString();
const failedProjects = (JSON.parse(value) as string[])
.map((project) => `- ${new URL(project).origin}`)
.join("\n");
throw {
message: "Logout failed. Please try again later",
description: `Something went wrong during the projects logout. Please try again later.\nProjects failed to logout:\n${failedProjects}`,
};
}
window.location.href = parsedData.data.redirectTo;
return;
});
const handleFinish = useEffectEvent((failedProjects?: string[]) => {
if (failedProjects !== undefined) {
const elt = document.createElement("button");
elt.type = "submit";
elt.name = "error";
elt.value = JSON.stringify(failedProjects);
elt.style.display = "none";
refForm.current?.appendChild(elt);
refForm.current?.requestSubmit(elt);
return;
}
refForm.current?.requestSubmit();
});
return (
// oxlint-disable-next-line react-hooks/rules-of-hooks -- our useEffectEvent is a stable callback
<form ref={refForm} action={handleLogout}>
{/* oxlint-disable-next-line react-hooks/rules-of-hooks -- our useEffectEvent is a stable callback */}
<Logout logoutUrls={props.logoutUrls} onFinish={handleFinish} />
</form>
);
};