Skip to content
Merged
  •  
  •  
  •  
27 changes: 24 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions src/app/knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@
"predev",
"printf",
"pbcopy",
"xclip",
"taskkill"
"xclip"
],
"vitest": {
"entry": ["test/**/*.test.ts"]
Expand Down
7 changes: 4 additions & 3 deletions src/web/blog/src/app/blog/(index)/recent-posts.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useMemo, useState, type Ref } from "react";
import { useAtom, useCreateAtom } from "@tanstack/react-store";
import { useEffect, useMemo, type Ref } from "react";
import Image from "next/image";
import Link from "next/link";
import {
Expand Down Expand Up @@ -51,7 +52,7 @@ export function replaceRecentPostsTopicUrl(topicId: string) {
}

export function RecentPosts({ posts, topics }: RecentPostsProps) {
const [selectedTopicId, setSelectedTopicId] = useState(allTopicsId);
const [selectedTopicId, setSelectedTopicId] = useAtom(useCreateAtom(allTopicsId));
const breakpoint = useBreakpoint();
const hoverCapable = useHoverCapable();
const topicIds = useMemo(() => new Set(topics.map((topic) => topic.id)), [topics]);
Expand All @@ -77,7 +78,7 @@ export function RecentPosts({ posts, topics }: RecentPostsProps) {

useEffect(() => {
return subscribeToRecentPostsTopicHash(topicIds, setSelectedTopicId);
}, [topicIds]);
}, [setSelectedTopicId, topicIds]);

const selectTopic = (topicId: string) => {
setSelectedTopicId(topicId);
Expand Down
7 changes: 2 additions & 5 deletions src/web/knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@
"blog/src/app/globals.css",
"blog/custom-worker.ts",
"blog/mdx-components.tsx",
"blog/next.config.ts",
"blog/open-next.config.ts",
"blog/scripts/**/*.ts",
"blog/src/content/**/*.mdx",
"auth/index.ts",
"scripts/**/*.{ts,mjs}"
],
"project": ["src/**/*.{ts,tsx}", "blog/**/*.{ts,tsx}", "auth/**/*.{ts,tsx,mts}", "scripts/**/*.{ts,mjs}", "test-runtime/**/*.ts"],
"project": ["src/**/*.{ts,tsx,css,mdx}", "blog/**/*.{ts,tsx,css,mdx}", "auth/**/*.{ts,tsx,mts}", "scripts/**/*.{ts,mjs}", "test-runtime/**/*.ts"],
"ignoreDependencies": [
"@tailwindcss/node",
"cloudflare",
Expand All @@ -34,9 +34,6 @@
"rehype-slug",
"shiki"
],
"ignoreBinaries": [
"lsof"
],
"ignore": [
"src/components/ui/**",
"src/app/(app)/workspaces/client.tsx"
Expand Down
5 changes: 3 additions & 2 deletions src/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@
"@tanstack/react-db": "0.4.1",
"@tanstack/react-query": "5.103.2",
"@tanstack/react-query-persist-client": "5.103.2",
"@tanstack/react-store": "0.11.2",
"@tanstack/react-virtual": "^3.14.13",
"@tanstack/store": "0.11.2",
"@tauri-apps/plugin-clipboard-manager": "^2.3.3",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
Expand Down Expand Up @@ -134,8 +136,7 @@
"tailwind-merge": "^3.7.0",
"tw-animate-css": "^1.4.0",
"unique-names-generator": "^4.7.1",
"zod": "^4.6.5",
"zustand": "^4.5.7"
"zod": "^4.6.5"
},
"devDependencies": {
"@alook/test-utils": "workspace:*",
Expand Down
6 changes: 4 additions & 2 deletions src/web/scripts/dev-zone-processes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ describe("development zone Worker processes", () => {
])
})

it("keeps main and ws-do in one exact Wrangler runtime for E2E", () => {
it("keeps main, ws-do and queue consumer in one exact Wrangler runtime for E2E", () => {
expect(exactWorkerDevArgs(
"/wrangler/bin/wrangler.js",
["wrangler.toml", "../ws-do/wrangler.toml"],
["wrangler.toml", "../ws-do/wrangler.toml", "../queue-worker/wrangler.toml"],
LOCAL_WORKER_ENDPOINTS.main,
)).toEqual([
"/wrangler/bin/wrangler.js",
Expand All @@ -48,6 +48,8 @@ describe("development zone Worker processes", () => {
"wrangler.toml",
"--config",
"../ws-do/wrangler.toml",
"--config",
"../queue-worker/wrangler.toml",
"--local",
"--persist-to",
".wrangler/state",
Expand Down
2 changes: 1 addition & 1 deletion src/web/scripts/dev-zones.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ async function waitForHttpOk(url, timeoutMs = 60_000) {
}

const mainWorkerConfigs = withWsDo
? ["wrangler.toml", "../ws-do/wrangler.toml"]
? ["wrangler.toml", "../ws-do/wrangler.toml", "../queue-worker/wrangler.toml"]
: ["wrangler.toml"]
const workerCommand = wranglerEntry ? process.execPath : packageManagerCli
const workerArgs = (configPaths, endpoint) => wranglerEntry
Expand Down
53 changes: 28 additions & 25 deletions src/web/src/app/(app)/invite/[token]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"use client";

import { useEffect, useState } from "react";

import { useQuery, useMutation, type Query } from "@tanstack/react-query";
import { applicationKey, useApplicationOwner, runApplicationRequest } from "@/lib/application-owner";
import { useApplicationViewSource } from "@/hooks/use-application-view-source";
import { useParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import { getInviteInfo, acceptInvite, type InviteInfo } from "@/lib/api";
import { getInviteInfo, acceptInvite } from "@/lib/api";
import { trackInviteAccepted } from "@/lib/analytics";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
Expand All @@ -14,35 +17,35 @@ export default function InvitePage() {
const { token } = useParams<{ token: string }>();
const router = useRouter();

const [state, setState] = useState<State>("loading");
const [info, setInfo] = useState<InviteInfo | null>(null);
const [errorMsg, setErrorMsg] = useState<string>("");

useEffect(() => {
if (!token) return;
getInviteInfo(token)
.then((data) => {
setInfo(data);
setState("ready");
})
.catch((err) => {
setErrorMsg(err instanceof Error ? err.message : "Invalid or expired invite link");
setState("error");
});
}, [token]);
const owner = useApplicationOwner();
const source = useApplicationViewSource(`workspace-invite:${token}`);
const query = useQuery({ queryKey: applicationKey(owner, "workspace-invite", token), enabled: !!token, retry: false,
queryFn: ({ signal }) => runApplicationRequest(owner, (options) => getInviteInfo(token, options), signal) });
const command = useMutation({ mutationKey: applicationKey(owner, "workspace-invite", token, "accept"), gcTime: 0,
mutationFn: async ({ original, resources }: { original: ReturnType<typeof source.capture>; resources: Query[] }) => {
original.assert();
const result = await acceptInvite(token, { authenticationAccount: owner.userId, signal: original.signal, assertActive: original.assert, onUnauthorized: async () => { original.assert(); return false; } });
original.assert();
await owner.queryClient.invalidateQueries({ queryKey: applicationKey(owner, "workspaces"), predicate: (query) => resources.includes(query) }, { cancelRefetch: false });
original.assert();
return result;
} });
const info = query.data;
const error = command.error ?? query.error;
const errorMsg = error instanceof Error ? error.message : "Invalid or expired invite link";
const state: State = command.isPending ? "accepting" : command.isSuccess ? "done" : error ? "error" : query.isPending ? "loading" : "ready";

const handleAccept = async () => {
if (!token) return;
setState("accepting");
if (!token || owner.queryClient.isMutating({ mutationKey: applicationKey(owner, "workspace-invite", token, "accept"), exact: true })) return;
const original = source.capture();
try {
const result = await acceptInvite(token);
const result = await command.mutateAsync({ original, resources: owner.queryClient.getQueryCache().findAll({ queryKey: applicationKey(owner, "workspaces") }) });
original.assert();
trackInviteAccepted({ workspace_id: result.workspace_id ?? "" });
setState("done");
toast.success(`Joined ${info?.workspace_name ?? "workspace"}`);
router.replace(`/w/${result.workspace_slug}/home`);
} catch (err) {
setErrorMsg(err instanceof Error ? err.message : "Failed to join workspace");
setState("error");
} catch {
try { original.assert(); } catch { return; }
}
};

Expand Down
52 changes: 52 additions & 0 deletions src/web/src/app/(app)/layout.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useLayoutEffect } from "react"
import "fake-indexeddb/auto"
import React from "react"
import { get, set } from "idb-keyval"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { act, render, waitFor } from "@/test/react-dom-harness"
import AppLayout from "./layout"
import { useApplicationOwner, type ApplicationOwner } from "@/lib/application-owner"
import { clearAllPersistedCaches, createIdbPersister } from "@/lib/query-persister"

const input = vi.hoisted(() => ({ serverId: "A", sdk: { data: { user: { id: "A" } }, isPending: false, error: null } }))
vi.mock("@/lib/session", () => ({ getSession: async () => ({ user: { id: input.serverId } }) }))
vi.mock("@/lib/auth-client", () => { const sessionSDK = { useSession: () => input.sdk }; return { ...sessionSDK, currentSessionViewer: () => { const value = sessionSDK.useSession(); return !value || value.isPending || value.error ? undefined : value.data?.user.id ?? null } } })
vi.mock("next/navigation", () => ({ usePathname: () => "/w/test", useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }), redirect: vi.fn() }))
vi.mock("@/components/authenticated-native-oauth-cleanup", () => ({ AuthenticatedNativeOauthCleanup: () => null }))
vi.mock("@/components/signup-tracker", () => ({ SignupTracker: () => null }))
vi.mock("@/components/signin-tracker", () => ({ SigninTracker: () => null }))
vi.mock("@/components/daemon-update-notice", () => ({ DaemonUpdateNotice: () => null }))
const owners: Record<string, ApplicationOwner> = {}
function Probe() { const owner = useApplicationOwner(); useLayoutEffect(() => { owners[owner.userId] = owner }); return <output>{owner.userId}</output> }
async function layout(id: string) { input.serverId = id; return AppLayout({ children: <Probe /> }) }
beforeEach(async () => { await clearAllPersistedCaches(); input.sdk = { data: { user: { id: "A" } }, isPending: false, error: null } })
afterEach(async () => {
await act(async () => {
for (const owner of Object.values(owners)) owner.queryClient.clear(); for (const key of Object.keys(owners)) delete owners[key]
})
})

describe("actual authenticated AppLayout identity boundary", () => {
it("retires A when immutable public SDK output and the server layout account change in one commit", async () => {
const view = render(await layout("A"))
await waitFor(() => expect(owners.A?.sessionViewer()).toBe("A"))
const old = owners.A, a = createIdbPersister("A", "application"), b = createIdbPersister("B", "application")
await act(async () => { await a.restoreClient(); }) ; await act(async () => { await b.restoreClient(); }) ; await act(async () => { await set("alook:qc:v2:A:client", "old A"); }) ; await act(async () => { await set("alook:qc:v2:B:client", "old B") }) ;
const next = await layout("B")
act(() => { input.sdk = { data: { user: { id: "B" } }, isPending: false, error: null }; view.rerender(next) })
await waitFor(() => expect(owners.B?.sessionViewer()).toBe("B"))
expect(old.lifecycle.get().active).toBe(false)
await waitFor(async () => expect(await a.isCurrent()).toBe(false))
expect(await get("alook:qc:v2:A:client")).toBeUndefined()
expect(await get("alook:qc:v2:B:client")).toBe("old B"); expect(await b.isCurrent()).toBe(true)
expect(owners.B.lifecycle.get().active).toBe(true)
})
it("preserves A disk when the same-viewer layout leaves an ordinary route", async () => {
const view = render(await layout("A"))
await waitFor(() => expect(owners.A?.sessionViewer()).toBe("A"))
const a = createIdbPersister("A", "application"); await act(async () => { await a.restoreClient(); }) ; await act(async () => { await set("alook:qc:v2:A:client", "old A") }) ;
act(() => view.rerender(<p>Public route</p>))
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) })
expect(await a.isCurrent()).toBe(true); expect(await get("alook:qc:v2:A:client")).toBe("old A")
})
})
3 changes: 3 additions & 0 deletions src/web/src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ApplicationQueryProvider } from "@/lib/application-owner";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
Expand All @@ -20,12 +21,14 @@ export default async function AppLayout({
if (!session) redirect("/sign-in");

return (
<ApplicationQueryProvider userId={session.user.id}>
<AuthenticatedContextMenuBoundary>
<AuthenticatedNativeOauthCleanup />
<SignupTracker />
<SigninTracker />
<DaemonUpdateNotice userId={session.user.id} />
{children}
</AuthenticatedContextMenuBoundary>
</ApplicationQueryProvider>
);
}
Loading
Loading