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
5 changes: 5 additions & 0 deletions .changeset/hot-icons-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ownmail/app": patch
---

Refresh invalidated cached drafts before initializing compose, and keep mutable mailbox information current after settings changes.
9 changes: 9 additions & 0 deletions labs/ownmail/packages/app/src/app/query/mailbox-info.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { queryOptions } from '@tanstack/react-query'
import { getMailboxInfo } from '#server/fns'

export const mailboxInfoQueryOptions = () =>
queryOptions({
queryKey: ['account', 'mailbox-info'] as const,
queryFn: () => getMailboxInfo(),
staleTime: 30_000,
})
34 changes: 32 additions & 2 deletions labs/ownmail/packages/app/src/routes/-mail.compose.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { renderToString } from 'react-dom/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { markdownToDraftBody } from '#features/mail/lib/html-to-markdown'
import { markdownToEmailHtml } from '#features/mail/lib/markdown-model'
import { mailKeys } from '#features/mail/state/mail-queries'

// TanStack router/start are stubbed so the route module can be imported and its
// loader/component exercised directly without a live router. `navigate` and
Expand Down Expand Up @@ -205,9 +206,12 @@ function fileInput(container: HTMLElement) {
return container.querySelector('input[type="file"]') as HTMLInputElement
}

function runComposeLoader(deps: Record<string, string | undefined>) {
function runComposeLoader(
deps: Record<string, string | undefined>,
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
) {
return Route.options.loader({
context: { queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }) },
context: { queryClient },
deps,
})
}
Expand Down Expand Up @@ -294,6 +298,32 @@ describe('mail.compose loader', () => {
expect(data.draft?.id).toBe('d0')
})

it('refetches an invalidated cached draft before initializing compose', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 30_000 } },
})
queryClient.setQueryData(mailKeys.draft('d0'), {
id: 'd0',
subject: 'Obsolete cached subject',
body: 'Obsolete cached body',
})
await queryClient.invalidateQueries({ queryKey: mailKeys.draft('d0'), refetchType: 'none' })
getDraft.mockResolvedValue({
id: 'd0',
subject: 'Current server subject',
body: 'Current server body',
})

const data = await runComposeLoader({ draft: 'd0' }, queryClient)

expect(getDraft).toHaveBeenCalledWith({ data: { draftId: 'd0' } })
expect(data.draft).toMatchObject({
id: 'd0',
subject: 'Current server subject',
body: 'Current server body',
})
})

it('builds a reply payload that carries the reply-to message id when replying', async () => {
const data = await runComposeLoader({
replyToMessageId: 'm9',
Expand Down
20 changes: 19 additions & 1 deletion labs/ownmail/packages/app/src/routes/-mail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ vi.mock('#server/fns', () => ({
vi.mock('#app/components/AppRail', () => ({
AppRailLogo: ({ appName }: { appName: string }) => <div data-testid="logo">{appName}</div>,
AppRailNav: (props: any) => (
<div data-testid="railnav" data-email={props.email}>
<div data-testid="railnav" data-email={props.email} data-display-name={props.displayName}>
<button type="button" aria-label="Open command palette" onClick={props.onOpenCommandPalette}>
rail-open-palette
</button>
Expand Down Expand Up @@ -172,6 +172,24 @@ describe('/mail loader + layout', () => {
expect(screen.getByTestId('logo')).toHaveTextContent('OwnMail')
expect(screen.getByTestId('railnav')).toHaveAttribute('data-email', 'ada@example.com')
})

it('renders current observed mailbox info instead of stale infinite-route loader data', () => {
Route.useLoaderData = vi.fn(() => ({ info, folders: [] as Folder[] }))
const queryClient = new QueryClient()
queryClient.setQueryData(['account', 'mailbox-info'], {
...info,
displayName: 'Ada Lovelace',
})
const Component = Route.options.component

render(
<QueryClientProvider client={queryClient}>
<Component />
</QueryClientProvider>,
)

expect(screen.getByTestId('railnav')).toHaveAttribute('data-display-name', 'Ada Lovelace')
})
})

describe('MailRouteScreen — layout wiring', () => {
Expand Down
18 changes: 16 additions & 2 deletions labs/ownmail/packages/app/src/routes/-settings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -40,6 +41,7 @@ vi.mock('#shared/components/Sheet', () => ({
) : null,
}))

import { mailboxInfoQueryOptions } from '#app/query/mailbox-info'
import { Route } from './settings.js'

const info = { email: 'ada@example.com', displayName: 'Ada', appName: 'OwnMail' }
Expand All @@ -48,10 +50,15 @@ const password = 'StrongPassword123!More'
function renderSettings(
passwordResetEnabled = false,
loaderInfo: { email: string; displayName?: string; appName: string } = info,
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
) {
Route.useLoaderData = vi.fn(() => ({ info: loaderInfo, capabilities: { passwordResetEnabled } }))
const Component = Route.options.component
return render(<Component />)
return render(
<QueryClientProvider client={queryClient}>
<Component />
</QueryClientProvider>,
)
}

beforeEach(() => {
Expand Down Expand Up @@ -174,7 +181,9 @@ describe('/settings', () => {
})

it('persists the account name before saving device preferences', async () => {
renderSettings()
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
queryClient.setQueryData(mailboxInfoQueryOptions().queryKey, info)
renderSettings(false, info, queryClient)
expect(screen.getByLabelText('Darken email content automatically')).toBeChecked()
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: ' Ada Lovelace ' } })
fireEvent.click(screen.getByLabelText('Save recipients to contacts automatically'))
Expand All @@ -186,6 +195,11 @@ describe('/settings', () => {

expect(await screen.findByText('Settings saved.')).toBeInTheDocument()
expect(updateMailboxDisplayName).toHaveBeenCalledWith({ data: { displayName: 'Ada Lovelace' } })
expect(queryClient.getQueryData(mailboxInfoQueryOptions().queryKey)).toMatchObject({
email: 'ada@example.com',
displayName: 'Ada Lovelace',
appName: 'OwnMail',
})
expect(JSON.parse(window.localStorage.getItem('ownmail:user-preferences:v1') ?? '{}')).toEqual({
displayName: 'Ada Lovelace',
autoSaveContacts: false,
Expand Down
2 changes: 1 addition & 1 deletion labs/ownmail/packages/app/src/routes/mail.compose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const Route = createFileRoute('/mail/compose')({
loader: async ({ context, deps }) => {
const folderId = deps.folderId ?? 'inbox'
const draft = deps.draft
? await context.queryClient.ensureQueryData(
? await context.queryClient.fetchQuery(
draftQueryOptions(deps.draft, (draftId) => getDraft({ data: { draftId } })),
)
: null
Expand Down
19 changes: 9 additions & 10 deletions labs/ownmail/packages/app/src/routes/mail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Folder } from '@nylas-labs/cli-kit/v3'
import { queryOptions, useQuery } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
import { Menu, Pencil } from 'lucide-react'
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
Expand All @@ -11,6 +11,7 @@ import {
MAIL_HEADER_GRID_CLASS,
MAIL_SIDEBAR_WIDTH_CLASS,
} from '#app/config/layout'
import { mailboxInfoQueryOptions } from '#app/query/mailbox-info'
import { MailSearchBar } from '#features/mail/components/MailSearchBar'
import { MailSidebar } from '#features/mail/components/MailSidebar'
import {
Expand All @@ -20,17 +21,10 @@ import {
mailSearchInputValue,
} from '#features/mail/lib/mail-ui-model'
import { foldersQueryOptions } from '#features/mail/state/mail-queries'
import { getFolders, getMailboxInfo } from '#server/fns'
import { getFolders } from '#server/fns'
import { Sheet } from '#shared/components/Sheet'
import { cn } from '#shared/lib/utils'

const mailboxInfoQueryOptions = () =>
queryOptions({
queryKey: ['account', 'mailbox-info'] as const,
queryFn: () => getMailboxInfo(),
staleTime: Number.POSITIVE_INFINITY,
})

export const Route = createFileRoute('/mail')({
loader: async ({ context }) => {
const [info, folders] = await Promise.all([
Expand All @@ -51,7 +45,12 @@ type MailInfo = {
}

function MailLayout() {
const { info, folders: initialFolders } = Route.useLoaderData()
const { info: initialInfo, folders: initialFolders } = Route.useLoaderData()
const { data: info } = useQuery({
...mailboxInfoQueryOptions(),
initialData: initialInfo,
initialDataUpdatedAt: 0,
})
const { data: folders } = useQuery({
...foldersQueryOptions(() => getFolders()),
initialData: initialFolders,
Expand Down
8 changes: 8 additions & 0 deletions labs/ownmail/packages/app/src/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* Hallmark · component: Settings mobile header · genre: modern-minimal · theme: Quiet · pre-emit critique: P5 H5 E5 S5 R5 V5 */

import { useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Check, KeyRound, LogOut, Menu, Settings as SettingsIcon, UserRound } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
Expand All @@ -11,6 +13,7 @@ import {
type UserPreferences,
useUserPreferences,
} from '#app/preferences/user-preferences'
import { mailboxInfoQueryOptions } from '#app/query/mailbox-info'
import {
getAccountCapabilities,
getMailboxInfo,
Expand Down Expand Up @@ -67,6 +70,7 @@ function preferencesMatch(left: UserPreferences, right: UserPreferences): boolea

function SettingsPage() {
const { info, capabilities } = Route.useLoaderData()
const queryClient = useQueryClient()
const [preferences, savePreferences] = useUserPreferences()
const [draft, setDraft] = useState<UserPreferences>(preferences)
const [displayName, setDisplayName] = useState(info.displayName ?? '')
Expand Down Expand Up @@ -130,6 +134,10 @@ function SettingsPage() {
? { displayName: persistedDisplayName }
: await updateMailboxDisplayName({ data: { displayName: snapshot.displayName } })
if (settingsRevisionRef.current !== revision) return
queryClient.setQueryData(mailboxInfoQueryOptions().queryKey, {
...info,
displayName: account.displayName,
})
Comment on lines +137 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid refreshing the cache from stale Settings data

When the user changes only a browser preference, account is built from persistedDisplayName without contacting the server, but this unconditional write marks the loader's mailbox snapshot fresh. If the display name changed on another device after Settings loaded, navigating to Mail now reuses the old name instead of fetching the current mailbox info; update this cache only after a display-name mutation, or invalidate/refetch it for preference-only saves.

Useful? React with 👍 / 👎.

savePreferences({
...snapshot,
displayName: account.displayName,
Expand Down