diff --git a/.changeset/happy-trams-cheat.md b/.changeset/happy-trams-cheat.md new file mode 100644 index 00000000..ae4d5959 --- /dev/null +++ b/.changeset/happy-trams-cheat.md @@ -0,0 +1,5 @@ +--- +"@ownmail/app": patch +--- + +Fix authenticated mailbox server rendering and isolate compose state when switching backdrop drafts. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17c2f7e6..7426e439 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm turbo build typecheck test --filter=...{labs/ownmail/packages/*} + - run: pnpm --filter @ownmail/app smoke:ssr - run: pnpm smoke:ownmail-release shared: diff --git a/labs/ownmail/packages/app/package.json b/labs/ownmail/packages/app/package.json index 1756aa0d..60de21d6 100644 --- a/labs/ownmail/packages/app/package.json +++ b/labs/ownmail/packages/app/package.json @@ -60,6 +60,7 @@ "build:cloudflare": "vite build", "build:node": "vite build -c vite.config.local.ts --outDir dist-vercel", "build:vercel": "vite build -c vite.config.vercel.ts", + "smoke:ssr": "node scripts/smoke-ssr.mjs", "test": "vitest run --coverage", "coverage": "vitest run --coverage", "pretypecheck": "pnpm --filter @nylas-labs/cli-kit build", diff --git a/labs/ownmail/packages/app/scripts/smoke-ssr.mjs b/labs/ownmail/packages/app/scripts/smoke-ssr.mjs new file mode 100644 index 00000000..dca527fd --- /dev/null +++ b/labs/ownmail/packages/app/scripts/smoke-ssr.mjs @@ -0,0 +1,85 @@ +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const host = '127.0.0.1' +const port = await availablePort(host) +const origin = `http://${host}:${port}` +const output = [] +const server = spawn(process.execPath, ['scripts/serve-node.mjs'], { + cwd: appRoot, + env: { + ...process.env, + APP_NAME: 'ownmail-ssr-smoke', + HOST: host, + INBOX_EMAIL: 'ada@ownmail.test', + NODE_ENV: 'development', + NYLAS_API_KEY: 'smoke-test-key', + NYLAS_CLIENT_ID: 'smoke-test-client', + NYLAS_REGION: 'us', + OWNMAIL_DEV_MOCKS: '1', + PORT: String(port), + SESSION_SECRET: 'ownmail-ssr-smoke-session-secret', + TEMPLATE_VERSION: '0.0.0-smoke', + }, + stdio: ['ignore', 'pipe', 'pipe'], +}) + +server.stdout.on('data', (chunk) => output.push(chunk.toString())) +server.stderr.on('data', (chunk) => output.push(chunk.toString())) + +try { + await waitForServer(`${origin}/healthz`) + const response = await fetch(`${origin}/mail/f/inbox`, { + headers: { Cookie: 'ownmail_session=authenticated' }, + redirect: 'manual', + signal: AbortSignal.timeout(10_000), + }) + const body = await response.text() + if (response.status !== 200 || !/^\s*/i.test(body)) { + throw new Error( + `Authenticated mailbox SSR returned ${response.status} ${response.statusText || ''} with ${body.length} bytes.`, + ) + } + console.log('OwnMail authenticated mailbox SSR smoke test passed.') +} catch (error) { + const logs = output.join('').trim() + if (logs) process.stderr.write(`${logs}\n`) + throw error +} finally { + server.kill('SIGTERM') + await Promise.race([ + new Promise((resolve) => server.once('exit', resolve)), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]) + if (server.exitCode === null) server.kill('SIGKILL') +} + +async function availablePort(hostname) { + const probe = createServer() + await new Promise((resolve, reject) => { + probe.once('error', reject) + probe.listen(0, hostname, resolve) + }) + const address = probe.address() + if (!address || typeof address === 'string') throw new Error('Could not allocate an SSR smoke-test port.') + await new Promise((resolve, reject) => probe.close((error) => (error ? reject(error) : resolve()))) + return address.port +} + +async function waitForServer(url) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (server.exitCode !== null) throw new Error(`OwnMail SSR server exited with code ${server.exitCode}.`) + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }) + if (response.ok) return + } catch { + // The built server may still be starting; retry until the bounded deadline. + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('Timed out waiting for the OwnMail SSR server.') +} diff --git a/labs/ownmail/packages/app/src/-router.test.tsx b/labs/ownmail/packages/app/src/-router.test.tsx index f4c662f5..3a63433c 100644 --- a/labs/ownmail/packages/app/src/-router.test.tsx +++ b/labs/ownmail/packages/app/src/-router.test.tsx @@ -1,3 +1,5 @@ +import { createMemoryHistory, RouterContextProvider } from '@tanstack/react-router' +import { renderToString } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { getRouter } from './router.js' @@ -12,4 +14,27 @@ describe('getRouter', () => { // The route tree must be attached or every route 404s. expect(router.routeTree).toBeDefined() }) + + it('renders the query provider inside router context during SSR', () => { + const router = getRouter() + router.update({ ...router.options, history: createMemoryHistory({ initialEntries: ['/'] }) }) + const InnerWrap = router.options.InnerWrap + + // OwnmailQueryProvider calls useRouterState. TanStack Router's outer Wrap is + // above RouterContextProvider, so putting it there crashes SSR while reading + // router.stores. Keep this provider on the hook-safe side of the boundary. + expect(router.options.Wrap).toBeUndefined() + expect(InnerWrap).toBeTypeOf('function') + expect(() => + renderToString( + + {InnerWrap ? ( + +
mailbox
+
+ ) : null} +
, + ), + ).not.toThrow() + }) }) diff --git a/labs/ownmail/packages/app/src/router.tsx b/labs/ownmail/packages/app/src/router.tsx index 5ac9a34c..9c91682c 100644 --- a/labs/ownmail/packages/app/src/router.tsx +++ b/labs/ownmail/packages/app/src/router.tsx @@ -8,7 +8,7 @@ export function getRouter() { routeTree, context: { queryClient }, /* v8 ignore next -- the wrapper executes only inside TanStack Start's router runtime -- @preserve */ - Wrap: ({ children }) => {children}, + InnerWrap: ({ children }) => {children}, defaultPreload: 'intent', scrollRestoration: true, defaultPendingMinMs: 0, diff --git a/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx b/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx index a8adbe11..6d2fd1d2 100644 --- a/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx +++ b/labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx @@ -352,6 +352,74 @@ describe('mail.compose composer prefill', () => { ) }) + it('resets every draft-owned field when backdrop navigation selects another draft', async () => { + let search = { draft: 'draft-one' } + let loaderData = { + draft: { + id: 'draft-one', + to: [{ email: 'first@example.com' }], + subject: 'First subject', + body: 'First body', + }, + folders: [], + threads: [], + selected: null, + folderId: 'drafts', + reply: null, + } + Route.useLoaderData = vi.fn(() => loaderData) + Route.useSearch = vi.fn(() => search) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const view = render( + + + , + ) + + fireEvent.change(screen.getByLabelText('To'), { target: { value: 'edited-first@example.com' } }) + fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Edited first subject' } }) + fireEvent.change(screen.getByPlaceholderText('Write your message...'), { + target: { value: 'Edited first body' }, + }) + + search = { draft: 'draft-two' } + loaderData = { + ...loaderData, + draft: { + id: 'draft-two', + to: [{ email: 'second@example.com' }], + subject: 'Second subject', + body: 'Second body', + }, + } + view.rerender( + + + , + ) + + await waitFor(() => + expect((screen.getByPlaceholderText('Write your message...') as HTMLTextAreaElement).value).toBe( + 'Second body', + ), + ) + expect((screen.getByLabelText('To') as HTMLInputElement).value).toBe('second@example.com') + expect((screen.getByLabelText('Subject') as HTMLInputElement).value).toBe('Second subject') + + saveDraft.mockResolvedValue({ draftId: 'draft-two' }) + fireEvent.click(screen.getByRole('button', { name: /Send/ })) + await waitFor(() => + expect(saveDraft).toHaveBeenCalledWith({ + data: { + draftId: 'draft-two', + to: 'second@example.com', + subject: 'Second subject', + body: markdownToDraftBody('Second body'), + }, + }), + ) + }) + it('converts an enveloped markdown draft back to its source before prefilling the editor', () => { renderCompose({ loader: { draft: { id: 'd0', body: markdownToDraftBody('**Draft body**') } }, diff --git a/labs/ownmail/packages/app/src/routes/mail.compose.tsx b/labs/ownmail/packages/app/src/routes/mail.compose.tsx index 24f88ff0..083996a2 100644 --- a/labs/ownmail/packages/app/src/routes/mail.compose.tsx +++ b/labs/ownmail/packages/app/src/routes/mail.compose.tsx @@ -165,9 +165,14 @@ export const Route = createFileRoute('/mail/compose')({ : null, } }, - component: Compose, + component: ComposeRoute, }) +function ComposeRoute() { + const requestedDraftId = Route.useSearch().draft + return +} + function Compose() { const initial = Route.useLoaderData() // Compatibility for server-rendered and preloaded route data while the query