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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.85.11
- uses: taiki-e/install-action@b6b84cf49ebfe0176417bdce007c624f0db37f20 # v2.86.2
with:
tool: typos
# Markdown/prose only. Code + i18n catalogs carry their own vocabulary
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ jobs:
# publishes via OIDC (npm exchanges the workflow identity, no token).
- name: Version PR or publish
if: steps.detect.outputs.run == 'true'
# Held at v1 deliberately. v2 is only compatible with Changesets v3,
# which changes how peer-dependent packages are bumped — the exact
# thing `onlyUpdatePeerDependentsWhenOutOfRange` in .changeset/config.json
# is tuned around. v2 detects published packages by reading the file named
# by CHANGESETS_OUTPUT, which the v2 CLI never writes, so pairing v2 with
# our CLI publishes to npm and then skips every tag and GitHub release —
# and only warns. Move both together or neither.
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
version: pnpm version-packages
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"dependencies": {
"@astrojs/starlight": ">=0.41.5 <0.41.7",
"astro": "^7.1.1",
"astro": ">=7.2.3 <7.2.4",
"starlight-auto-sidebar": "^0.4.0",
"starlight-links-validator": "^0.25.3",
"starlight-llms-txt": "^0.11.0"
Expand Down
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@
},
"devDependencies": {
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"@commitlint/cli": "^21.2.1",
"@commitlint/config-conventional": "^21.2.0",
"@commitlint/config-pnpm-scopes": "^21.2.0",
"@changesets/cli": "^2.31.1",
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@commitlint/config-pnpm-scopes": "^21.2.2",
"@commitlint/types": "^21.2.0",
"@types/node": "catalog:",
"knip": "^6.24.0",
"knip": "^6.32.2",
"pkg-pr-new": "^0.0.75",
"turbo": "^2.10.8"
"turbo": "^2.10.11"
},
"pnpm": {
"overrides": {
Expand All @@ -64,7 +64,8 @@
"body-parser@<2.3.0": ">=2.3.0",
"sharp@<0.35.0": ">=0.35.0",
"nanoid@<3.3.17": ">=3.3.17 <4",
"ip-address@<10.3.1": ">=10.3.1 <11"
"ip-address@<10.3.1": ">=10.3.1 <11",
"@astrojs/markdown-satteri": ">=0.3.1 <0.3.7"
}
}
}
2 changes: 1 addition & 1 deletion packages/admin-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"@tiptap/extension-text": "catalog:tiptap",
"@tiptap/extensions": "catalog:tiptap",
"@tiptap/react": "catalog:tiptap",
"zustand": "^5.0.14"
"zustand": "^5.0.15"
},
"peerDependencies": {
"@lingui/core": "catalog:lingui",
Expand Down
4 changes: 2 additions & 2 deletions packages/admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "catalog:",
"lucide-react": "^1.31.0",
"lucide-react": "^1.34.0",
"radix-ui": "catalog:",
"react-colorful": "^5.7.0",
"react-colorful": "^5.8.0",
"react-hook-form": "catalog:",
"sonner": "catalog:",
"tailwind-merge": "catalog:"
Expand Down
6 changes: 3 additions & 3 deletions packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@
"@uiw/react-codemirror": "^4.25.11"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@axe-core/playwright": "^4.13.0",
"@babel/core": "^8.0.1",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^5.7.1",
"@hookform/resolvers": "^5.9.1",
"@lingui/babel-plugin-lingui-macro": "catalog:lingui",
"@lingui/cli": "catalog:lingui",
"@lingui/core": "catalog:lingui",
Expand All @@ -71,7 +71,7 @@
"@plumix/prettier-config": "workspace:*",
"@plumix/typescript-config": "workspace:*",
"@plumix/vitest-config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "catalog:tailwind",
"@tanstack/react-query": "catalog:tanstack",
"@tanstack/react-query-devtools": "catalog:tanstack",
Expand Down
52 changes: 27 additions & 25 deletions packages/admin/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
// TanStack Table's `useReactTable` returns stateful getters that cannot be
// memoized safely — React Compiler detects this automatically and skips
// compilation for this file, emitting a `react-hooks/incompatible-library`
// warning at the call site. No explicit `"use no memo"` directive: it's
// redundant with the compiler's auto-detection and CodeQL flags it as
// unknown. The warning is informational, not an error.

import type { MessageDescriptor } from "@lingui/core";
import type {
ColumnDef,
Expand All @@ -19,8 +12,10 @@ import { defineMessage } from "@lingui/core/macro";
import { Trans } from "@lingui/react";
import {
flexRender,
getCoreRowModel,
useReactTable,
metaHelper,
rowSelectionFeature,
tableFeatures,
useTable,
} from "@tanstack/react-table";

import { Skeleton } from "@plumix/admin-ui/skeleton";
Expand All @@ -43,14 +38,23 @@ const M = {
// Per-column alignment + className passthrough. Column defs opt in via
// `meta: { className: "text-right" }`; both the header cell and every
// body cell pick up the class so alignment stays in sync.
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
className?: string;
}
interface DataTableColumnMeta {
className?: string;
}

export function DataTable<TData>({
const features = tableFeatures({
rowSelectionFeature,
columnMeta: metaHelper<DataTableColumnMeta>(),
});

/** Callers use this instead of TanStack's `ColumnDef` so the registered
* feature set stays an implementation detail of `DataTable`. */
export type DataTableColumnDef<TData extends RowData> = ColumnDef<
typeof features,
TData
>;

export function DataTable<TData extends RowData>({
columns,
data,
isLoading = false,
Expand All @@ -60,7 +64,7 @@ export function DataTable<TData>({
onRowSelectionChange,
getRowId,
}: {
readonly columns: ColumnDef<TData>[];
readonly columns: DataTableColumnDef<TData>[];
readonly data: readonly TData[];
readonly isLoading?: boolean;
readonly emptyState?: ReactNode;
Expand All @@ -79,15 +83,10 @@ export function DataTable<TData>({
const label = useLabel();
const resolvedLoadingLabel = loadingLabel ?? label(M.loading);
const selectable = rowSelection !== undefined;
// `useReactTable` returns a non-stable table instance — React Compiler
// can't memoize the surrounding render. The instance is the documented
// contract (its methods and state are read by row/cell components)
// so there's no equivalent subscription-style API to migrate to.
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: data as TData[],
const table = useTable({
features,
data,
columns,
getCoreRowModel: getCoreRowModel(),
...(selectable
? {
enableRowSelection: true,
Expand Down Expand Up @@ -139,7 +138,10 @@ export function DataTable<TData>({
data-state={row.getIsSelected() ? "selected" : undefined}
className="group/row"
>
{row.getVisibleCells().map((cell) => (
{/* Not `getVisibleCells` — that comes from
`columnVisibilityFeature`, which stays unregistered so
cells and `getHeaderGroups` above span the same set. */}
{row.getAllCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(cell.column.columnDef.meta?.className)}
Expand Down
6 changes: 3 additions & 3 deletions packages/admin/src/editor/autosave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("isStaleConflictError", () => {
});

describe("classifyAutosaveError", () => {
const queryClient = { fetchQuery: vi.fn() } as never;
const queryClient = { query: vi.fn() } as never;

test("an invalid-block-content rejection is a hard failure the author must see", async () => {
// Regression: content that references a removed/unknown block is rejected
Expand Down Expand Up @@ -63,15 +63,15 @@ describe("classifyAutosaveError", () => {

test("a stale conflict recovers, re-anchoring on the refetched row", async () => {
const at = new Date("2026-07-04T00:00:00Z");
const qc = { fetchQuery: vi.fn().mockResolvedValue({ updatedAt: at }) };
const qc = { query: vi.fn().mockResolvedValue({ updatedAt: at }) };
const outcome = await classifyAutosaveError(staleConflict, qc as never, 1);
expect(outcome).toEqual({ kind: "recovered", updatedAt: at });
});

test("a stale conflict whose refetch fails recovers quietly (retry on next edit)", async () => {
// Must NOT become `failed` — the edit is intact and the next keystroke
// retries; toasting here would nag on ordinary concurrent-tab churn.
const qc = { fetchQuery: vi.fn().mockRejectedValue(new Error("offline")) };
const qc = { query: vi.fn().mockRejectedValue(new Error("offline")) };
const outcome = await classifyAutosaveError(staleConflict, qc as never, 1);
expect(outcome).toEqual({ kind: "recovered", updatedAt: null });
});
Expand Down
2 changes: 1 addition & 1 deletion packages/admin/src/editor/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ async function freshLiveUpdatedAt(
): Promise<Date | null> {
if (!isStaleConflictError(err)) return null;
try {
const fresh = await queryClient.fetchQuery({
const fresh = await queryClient.query({
...orpc.entry.get.queryOptions({ input: { id } }),
staleTime: 0,
});
Expand Down
9 changes: 8 additions & 1 deletion packages/admin/src/lib/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@ export const sessionQueryOptions = () =>

export const SESSION_QUERY_KEY = orpc.auth.session.queryKey({ input: {} });

// `static` short-circuits the freshness check entirely: whatever is already
// cached is served as-is, so a hard page load fetches the session exactly once
// no matter how many route guards read it on the way down.
export function loadSession(queryClient: QueryClient) {
return queryClient.query({ ...sessionQueryOptions(), staleTime: "static" });
}

/**
* Shared auth guard for layouts that require a signed-in user. Used by
* `_authenticated` (shell + sidebar) and `_editor` (full-screen canvas).
* Returns the user so the caller can spread it into route context.
* Throws a TanStack Router redirect on failure.
*/
export async function requireAuthenticatedSession(queryClient: QueryClient) {
const session = await queryClient.ensureQueryData(sessionQueryOptions());
const session = await loadSession(queryClient);
if (!session.user) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router control-flow; see https://tanstack.com/router/latest/docs/framework/react/guide/authenticated-routes
throw redirect({ to: session.needsBootstrap ? "/bootstrap" : "/login" });
Expand Down
8 changes: 4 additions & 4 deletions packages/admin/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ReactNode } from "react";
import { useEffect } from "react";
import { ErrorPlaceholder } from "@/components/error-placeholder.js";
import { pathToCrumbs } from "@/lib/breadcrumbs.js";
import { sessionQueryOptions } from "@/lib/session.js";
import { loadSession } from "@/lib/session.js";
import { useTheme } from "@/providers/theme.js";
import { Trans, useLingui } from "@lingui/react";
import {
Expand All @@ -22,10 +22,10 @@ const TITLE_BRAND = "Plumix Admin";

export const Route = createRootRouteWithContext<RouterAppContext>()({
// Kick off the session probe as early as possible so child routes' beforeLoad
// can synchronously read it via queryClient.getQueryData. ensureQueryData
// deduplicates: a single fetch per hard page load.
// can synchronously read it via queryClient.getQueryData. The `static`
// staleTime deduplicates: a single fetch per hard page load.
beforeLoad: async ({ context }) => {
await context.queryClient.ensureQueryData(sessionQueryOptions());
await loadSession(context.queryClient);
},
component: RootLayout,
notFoundComponent: NotFound,
Expand Down
6 changes: 2 additions & 4 deletions packages/admin/src/routes/_auth.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { sessionQueryOptions } from "@/lib/session.js";
import { loadSession } from "@/lib/session.js";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";

// Unauth-only layout: wraps /login and /bootstrap with the full-screen
Expand All @@ -8,9 +8,7 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
// be on /bootstrap vs /login) stay on the leaf routes.
export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
sessionQueryOptions(),
);
const session = await loadSession(context.queryClient);
if (session.user) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router redirect pattern
throw redirect({ to: "/" });
Expand Down
6 changes: 2 additions & 4 deletions packages/admin/src/routes/_auth/accept-invite/$token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LoginLocaleSwitcher } from "@/components/login-locale-switcher.js";
import { readManifest } from "@/lib/manifest.js";
import { PasskeyError, usePasskeyErrorMessage } from "@/lib/passkey-errors.js";
import { acceptInviteWithPasskey } from "@/lib/passkey.js";
import { SESSION_QUERY_KEY, sessionQueryOptions } from "@/lib/session.js";
import { loadSession, SESSION_QUERY_KEY } from "@/lib/session.js";
import { Trans, useLingui } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, redirect, useRouter } from "@tanstack/react-router";
Expand Down Expand Up @@ -40,9 +40,7 @@ import { langOnlySearchSchema } from "../-schemas.js";
export const Route = createFileRoute("/_auth/accept-invite/$token")({
validateSearch: langOnlySearchSchema,
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
sessionQueryOptions(),
);
const session = await loadSession(context.queryClient);
if (session.user) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router redirect pattern
throw redirect({ to: "/" });
Expand Down
6 changes: 2 additions & 4 deletions packages/admin/src/routes/_auth/bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LoginLocaleSwitcher } from "@/components/login-locale-switcher.js";
import { readManifest } from "@/lib/manifest.js";
import { PasskeyError, usePasskeyErrorMessage } from "@/lib/passkey-errors.js";
import { registerWithPasskey } from "@/lib/passkey.js";
import { SESSION_QUERY_KEY, sessionQueryOptions } from "@/lib/session.js";
import { loadSession, SESSION_QUERY_KEY } from "@/lib/session.js";
import { valibotResolver } from "@hookform/resolvers/valibot";
import { Trans, useLingui } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
Expand Down Expand Up @@ -36,9 +36,7 @@ import { bootstrapSchema, langOnlySearchSchema } from "./-schemas.js";
export const Route = createFileRoute("/_auth/bootstrap")({
validateSearch: langOnlySearchSchema,
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
sessionQueryOptions(),
);
const session = await loadSession(context.queryClient);
if (!session.needsBootstrap) {
// Once someone's claimed the bootstrap slot, /bootstrap becomes /login
// to avoid dead-end UX; the server would reject a post-bootstrap
Expand Down
6 changes: 2 additions & 4 deletions packages/admin/src/routes/_auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useOAuthErrorMessage } from "@/lib/oauth-errors.js";
import { orpc } from "@/lib/orpc.js";
import { PasskeyError, usePasskeyErrorMessage } from "@/lib/passkey-errors.js";
import { signInWithPasskey } from "@/lib/passkey.js";
import { SESSION_QUERY_KEY, sessionQueryOptions } from "@/lib/session.js";
import { loadSession, SESSION_QUERY_KEY } from "@/lib/session.js";
import { valibotResolver } from "@hookform/resolvers/valibot";
import { Trans, useLingui } from "@lingui/react";
import { useMutation, useQuery } from "@tanstack/react-query";
Expand Down Expand Up @@ -62,9 +62,7 @@ const loginSearchSchema = v.object({
export const Route = createFileRoute("/_auth/login")({
validateSearch: loginSearchSchema,
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
sessionQueryOptions(),
);
const session = await loadSession(context.queryClient);
if (session.needsBootstrap) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack Router redirect pattern
throw redirect({ to: "/bootstrap" });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { DataTableColumnDef } from "@/components/data-table/data-table.js";
import type { MessageDescriptor } from "@lingui/core";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import type { RowSelectionState } from "@tanstack/react-table";
import type { ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { DataTable } from "@/components/data-table/data-table.js";
Expand Down Expand Up @@ -368,8 +369,8 @@ function buildColumns({
renderLabel: (label: MessageDescriptor) => string;
editLabel: string;
formatDate: (value: Date, options?: Intl.DateTimeFormatOptions) => string;
}): ColumnDef<Entry>[] {
const selectColumn: ColumnDef<Entry> = {
}): DataTableColumnDef<Entry>[] {
const selectColumn: DataTableColumnDef<Entry> = {
id: "select",
meta: { className: "w-8" },
header: ({ table }) => {
Expand Down
7 changes: 4 additions & 3 deletions packages/admin/src/routes/_authenticated/settings/$page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ export const Route = createFileRoute("/_authenticated/settings/$page")({
const groups = groupsForSettingsPage(context.page);
return Promise.all(
groups.map((group) =>
context.queryClient.ensureQueryData(
orpc.settings.get.queryOptions({ input: { group: group.name } }),
),
context.queryClient.query({
...orpc.settings.get.queryOptions({ input: { group: group.name } }),
staleTime: "static",
}),
),
);
},
Expand Down
Loading
Loading