diff --git a/build.ts b/build.ts index 28e1633..0482ad2 100644 --- a/build.ts +++ b/build.ts @@ -10,6 +10,7 @@ const result = await Bun.build({ outdir, plugins: [tailwind], minify: true, + splitting: true, target: "bun", external: ["@napi-rs/canvas"], sourcemap: "linked", diff --git a/src/App.test.tsx b/src/App.test.tsx index 045fd09..91e3a83 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -91,6 +91,12 @@ function LocationProbe() { ; } +async function settleDeferred() { + for (let attempt = 0; attempt < 100 && [...container.querySelectorAll('[role="status"]')].some(node => node.textContent === "Loading…"); attempt++) { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 1)); }); + } +} + async function render(path = "/design-system") { await act(async () => root.render( @@ -100,6 +106,7 @@ async function render(path = "/design-system") { , ), ); + await settleDeferred(); } function connectAccount() { @@ -131,6 +138,7 @@ describe("homepage", () => { const opener = buttonNamed("Settings"); opener.focus(); await act(async () => opener.click()); + await settleDeferred(); const dialog = container.querySelector("dialog#account-settings")!; expect(dialog.open).toBe(true); expect(dialog.contains(document.activeElement)).toBe(true); @@ -158,6 +166,7 @@ describe("homepage", () => { expect(inner.open).toBe(true); expect(inner.querySelector('[aria-label="Close dialog"]')?.disabled).toBe(true); await act(async () => buttonNamed("Finish save").click()); + await settleDeferred(); await act(async () => inner.dispatchEvent(new dom.Event("cancel", { cancelable: true }) as unknown as Event)); expect(container.querySelector("#inner")).toBeNull(); expect(container.querySelector("#outer")?.open).toBe(true); @@ -175,10 +184,13 @@ describe("homepage", () => { await f.request(`/habits/${h.id}`, "DELETE"); await render("/"); await act(async () => buttonNamed("Archived habits").click()); + await settleDeferred(); expect(buttonNamed("Restore Reading")).toBeDefined(); await act(async () => buttonNamed("View history").click()); + await settleDeferred(); expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull(); await act(async () => buttonNamed("Restore Reading").click()); + await settleDeferred(); expect(container.textContent).toContain("No archived habits."); expect((await f.json("/today")).habits[0].name).toBe("Reading"); expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true); @@ -190,12 +202,16 @@ describe("homepage", () => { await f.request(`/habits/${habit.id}`, "DELETE"); await render("/"); await act(async () => buttonNamed("Archived habits").click()); + await settleDeferred(); await act(async () => buttonNamed("Delete permanently Discard me").click()); + await settleDeferred(); expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone."); expect(document.activeElement).toBe(buttonNamed("Keep habit")); await act(async () => buttonNamed("Keep habit").click()); + await settleDeferred(); expect((await f.request(`/habits/${habit.id}`)).status).toBe(200); await act(async () => buttonNamed("Delete permanently Discard me").click()); + await settleDeferred(); const confirm = () => container.querySelector("#delete-archived-habit .ds-action-row button:last-child")!; fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 })); await act(async () => confirm().click()); @@ -220,6 +236,7 @@ describe("homepage", () => { await act(async () => container.querySelector('[aria-label^="2026-09-03:"]')!.click()); expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026"); await act(async () => buttonNamed("Edit selected check-in").click()); + await settleDeferred(); const past = [...container.querySelectorAll('input[type="checkbox"]')].find(input => input.closest("label")?.textContent === "Completed on this date")!; expect(past).toBeDefined(); await act(async () => past.click()); @@ -233,6 +250,7 @@ describe("homepage", () => { expect(container.querySelector("h1")?.textContent).toBe("Small steps.Lasting rhythm."); expect(fetchMock.mock.calls.map(call => call[0])).toEqual(["/api/me"]); await act(async () => buttonNamed("Increase glasses of water").click()); + await settleDeferred(); expect(container.querySelector('[aria-label="Total glasses of water"]')?.value).toBe("4"); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -264,6 +282,7 @@ describe("homepage", () => { expect(container.textContent).toContain("Account unavailable"); expect(container.querySelector("#welcome-title")).toBeNull(); await act(async () => buttonNamed("Try again").click()); + await settleDeferred(); expect(container.querySelector("#welcome-title")).not.toBeNull(); }); @@ -273,6 +292,7 @@ describe("homepage", () => { expect(container.textContent).toContain("Welcome back, alice."); expect(container.textContent).toContain("Start with one habit."); await act(async () => buttonNamed("Add a habit +").click()); + await settleDeferred(); const input = container.querySelector("form input")!; expect(document.activeElement).toBe(input); await act(async () => { @@ -285,6 +305,7 @@ describe("homepage", () => { expect(container.textContent).not.toContain("Read a little created."); expect(container.querySelector(".ds-habit-chart")).not.toBeNull(); await act(async () => buttonNamed("Sign out").click()); + await settleDeferred(); expect(container.querySelector("#welcome-title")).not.toBeNull(); expect((await f.request("/me")).status).toBe(401); }); @@ -310,12 +331,14 @@ describe("homepage", () => { expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1); await act(async () => buttonNamed("Increase Water").click()); + await settleDeferred(); expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3"); expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3); expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete"); expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses"); expect(container.textContent).not.toContain("Sunday walk"); await act(async () => buttonNamed("All habits · 4").click()); + await settleDeferred(); const checkbox = (name: string) => [...container.querySelectorAll('input[type="checkbox"]')].find(input => (input.getAttribute("aria-label") || input.closest("label")?.textContent) === name)!; expect(checkbox("Sunday walk done").disabled).toBe(true); await act(async () => checkbox("Reading done").click()); @@ -323,6 +346,7 @@ describe("homepage", () => { expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("3 / 3"); expect((await f.json("/today")).completed).toBe(3); await act(async () => buttonNamed("Decrease Water").click()); + await settleDeferred(); expect((await f.json("/today")).completed).toBe(2); }); @@ -339,6 +363,7 @@ describe("homepage", () => { expect(calendarRequests()[0]).toContain("compact=true"); const parse = spyOn(Date, "parse"); await act(async () => buttonNamed("All habits · 2").click()); + await settleDeferred(); expect(parse).not.toHaveBeenCalled(); parse.mockRestore(); fetchMock.mockClear(); @@ -380,6 +405,7 @@ describe("homepage", () => { }, { preconnect: fetch.preconnect })); fetchMock.mockClear(); await act(async () => buttonNamed("Increase Water").click()); + await settleDeferred(); expect(buttonNamed("Increase Water").disabled).toBe(true); const check = container.querySelector('[aria-label="Reading done"]')!; expect(check.disabled).toBe(false); @@ -406,6 +432,7 @@ describe("homepage", () => { f.setTime("2026-09-04T12:00:00Z"); await render("/"); await act(async () => buttonNamed("Edit habit Water").click()); + await settleDeferred(); const form = container.querySelector('form[aria-label="Edit habit Water"]')!; expect(document.activeElement).toBe(form.querySelector("input")); await act(async () => { @@ -423,11 +450,15 @@ describe("homepage", () => { expect(container.querySelector("form")).toBeNull(); expect(container.querySelector("h3")?.textContent).toContain("Daily water"); await act(async () => buttonNamed("Archive habit Daily water").click()); + await settleDeferred(); expect((await f.json("/habits")).habits).toHaveLength(1); await act(async () => buttonNamed("Cancel").click()); + await settleDeferred(); expect((await f.json("/habits")).habits).toHaveLength(1); await act(async () => buttonNamed("Archive habit Daily water").click()); + await settleDeferred(); await act(async () => buttonNamed("Archive habit").click()); + await settleDeferred(); expect((await f.json("/habits")).habits).toHaveLength(0); expect(container.querySelector(".ds-habit-chart")).toBeNull(); const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); @@ -446,6 +477,7 @@ describe("homepage", () => { await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${task.id}`, "PUT", { done: true }); await render("/"); await act(async () => buttonNamed("Edit task Stretch").click()); + await settleDeferred(); const form = container.querySelector('form[aria-label="Edit task Stretch"]')!; await act(async () => { const input = form.querySelector("input")!; @@ -458,7 +490,9 @@ describe("homepage", () => { expect(saved.schedule).toEqual({ type: "weekdays", days: [4, 5] }); expect((await f.json("/today")).habits[0].value).toBe(1); await act(async () => buttonNamed("Delete task Stretch gently").click()); + await settleDeferred(); await act(async () => buttonNamed("Delete task").click()); + await settleDeferred(); const today = (await f.json("/today")).habits[0]; expect(today.target).toBe(1); expect(today.value).toBe(0); @@ -474,9 +508,11 @@ describe("homepage", () => { await render("/"); expect(container.querySelector(".ds-habit-chart")).toBeNull(); await act(async () => buttonNamed("All habits · 1").click()); + await settleDeferred(); expect(container.textContent).toContain("Not scheduled today"); expect(container.querySelector('.ds-editable-task input[type="checkbox"]')?.disabled).toBe(true); await act(async () => buttonNamed("Edit task Walk").click()); + await settleDeferred(); const form = container.querySelector('form[aria-label="Edit task Walk"]')!; await act(async () => { const input = form.querySelector("input")!; @@ -516,6 +552,7 @@ describe("homepage", () => { { type: "daily" }, ] as const) { await act(async () => buttonNamed("Edit task Walk").click()); + await settleDeferred(); const form = container.querySelector('form[aria-label="Edit task Walk"]')!; await selectValue(form.querySelector("select")!, schedule.type); if (schedule.type === "interval" || schedule.type === "weekly") { @@ -528,6 +565,7 @@ describe("homepage", () => { if (schedule.type === "interval") expect(buttonNamed("All habits · 1").getAttribute("aria-pressed")).toBe("true"); expect(container.querySelector("form")).toBeNull(); await act(async () => buttonNamed("Edit task Walk").click()); + await settleDeferred(); const reopened = container.querySelector('form[aria-label="Edit task Walk"]')!; expect(reopened.querySelector("select")?.value).toBe(schedule.type); if (schedule.type === "interval" || schedule.type === "weekly") { @@ -535,6 +573,7 @@ describe("homepage", () => { expect(reopened.querySelector('input[type="date"]')?.value).toBe(schedule.anchor); } await act(async () => buttonNamed("Cancel").click()); + await settleDeferred(); } const previous = await f.json(`/habits/${habit.id}/days/2026-09-03`); expect(previous.tasks[0].done).toBe(true); @@ -554,17 +593,20 @@ describe("homepage", () => { }; const submit = async () => act(async () => container.querySelector("form")!.dispatchEvent(new dom.Event("submit", { bubbles: true, cancelable: true }) as unknown as Event)); await act(async () => buttonNamed("Edit habit Reading").click()); + await settleDeferred(); await selectMethod("count"); await act(async () => container.querySelector('form input[type="checkbox"]')!.click()); await submit(); expect((await f.json(`/habits/${habit.id}`)).method).toBe("count"); expect((await f.json(`/habits/${habit.id}`)).carryPartialProgress).toBe(true); await act(async () => buttonNamed("Edit habit Reading").click()); + await settleDeferred(); expect(container.querySelector('form input[type="checkbox"]')?.checked).toBe(true); await selectMethod("tasks"); await submit(); expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks"); await act(async () => buttonNamed("Add task").click()); + await settleDeferred(); const form = container.querySelector('form[aria-label="Add task"]')!; await act(async () => { const input = form.querySelector("input")!; @@ -584,16 +626,19 @@ describe("homepage", () => { const habit = await f.json("/habits", "POST", { name: "Reading", method: "manual" }, 201); await render("/"); await act(async () => buttonNamed("Archive habit Reading").click()); + await settleDeferred(); fetchMock.mockImplementationOnce((async (input, init) => { return f.request(String(input).replace("/api", ""), init?.method); }) as typeof fetch); fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 })); await act(async () => buttonNamed("Archive habit").click()); + await settleDeferred(); expect(container.querySelector("form")).toBeNull(); expect(container.textContent).toContain("Your change was saved, but the dashboard could not refresh"); expect(buttonNamed("Archive habit Reading").disabled).toBe(true); expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true); await act(async () => buttonNamed("Try again").click()); + await settleDeferred(); expect(container.querySelector(".ds-habit-chart")).toBeNull(); expect(fetchMock.mock.calls.filter(call => call[1]?.method === "DELETE")).toHaveLength(1); }); @@ -604,10 +649,12 @@ describe("homepage", () => { await render("/"); fetchMock.mockResolvedValueOnce(Response.json({ error: "Save unavailable" }, { status: 500 })); await act(async () => buttonNamed("Increase Water").click()); + await settleDeferred(); expect(container.querySelector('[role="alert"]')?.textContent).toContain("Save unavailable"); expect(container.querySelector('[aria-label="Total Water"]')?.value).toBe("0"); expect(buttonNamed("Increase Water").disabled).toBe(false); await act(async () => buttonNamed("Increase Water").click()); + await settleDeferred(); expect(container.querySelector('[aria-label="Total Water"]')?.value).toBe("1"); expect((await f.json("/today")).habits[0].value).toBe(1); }); diff --git a/src/App.tsx b/src/App.tsx index 54c8998..d271b7f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Navigate, Route, Routes } from "react-router"; -import { DesignSystem } from "./pages/DesignSystem"; +import { deferred } from "./components/design-system/deferred"; +const DesignSystem = deferred(() => import("./pages/DesignSystem").then(module => module.DesignSystem)); import { Home } from "./pages/Home"; import { AuthProvider } from "./components/AuthProvider"; diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index ce5d688..c6a8345 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -10,7 +10,8 @@ import { Button } from "./design-system/primitives"; import { HabitChart } from "./design-system/HabitChart"; import { CalendarLegend } from "./design-system/CalendarLegend"; import { shade } from "../habits/calendar"; -import { CheckInEditor } from "./CheckInEditor"; +import { deferred } from "./design-system/deferred"; +const CheckInEditor = deferred(() => import("./CheckInEditor").then(module => module.CheckInEditor)); import { Field } from "./design-system/Field"; import { ItemActions } from "./design-system/ItemActions"; diff --git a/src/components/design-system/deferred.tsx b/src/components/design-system/deferred.tsx new file mode 100644 index 0000000..d5df3a7 --- /dev/null +++ b/src/components/design-system/deferred.tsx @@ -0,0 +1,9 @@ +import { lazy, Suspense, type ComponentProps, type ComponentType } from "react"; + +/** Keep the surrounding workspace usable while an optional surface loads. */ +export function deferred>(load: () => Promise) { + const Surface = lazy(async () => ({ default: await load() })) as ComponentType>; + return function Deferred(props: ComponentProps) { + return Loading…

}>
; + }; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index cc14f67..e221381 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,7 +1,8 @@ +import { deferred } from "../components/design-system/deferred"; import { CalendarCacheProvider } from "../components/CalendarCache"; import { Archive, Plus, Settings2 } from "lucide-react"; -import { ArchivedHabits } from "../components/ArchivedHabits"; -import { AccountSettings } from "../components/AccountSettings"; +const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits)); +const AccountSettings = deferred(() => import("../components/AccountSettings").then(module => module.AccountSettings)); import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "../components/AuthProvider"; import { DiscordSignInButton } from "../components/DiscordSignInButton"; @@ -16,17 +17,17 @@ import { import { Card, CardGrid } from "../components/design-system/Card"; import { HabitChart } from "../components/design-system/HabitChart"; import { HABIT_COLORS } from "../components/design-system/calendar-model"; -import { HabitForm } from "../components/HabitForm"; +const HabitForm = deferred(() => import("../components/HabitForm").then(module => module.HabitForm)); import { habitRequest, reconcileToday, scheduleLabel, type TodayHabit, type TodayResponse } from "../lib/dashboard"; import { HabitHistory } from "../components/HabitHistory"; import { WelcomePanel } from "../components/design-system/WelcomePanel"; import type { PublicUser } from "../shared/user"; import { ItemActions } from "../components/design-system/ItemActions"; import { type ItemMode } from "../components/InlineItemForm"; -import { InlineHabitEditor } from "../components/InlineHabitEditor"; -import { InlineTaskEditor } from "../components/InlineTaskEditor"; +const InlineHabitEditor = deferred(() => import("../components/InlineHabitEditor").then(module => module.InlineHabitEditor)); +const InlineTaskEditor = deferred(() => import("../components/InlineTaskEditor").then(module => module.InlineTaskEditor)); import type { Schedule } from "../habits/contracts"; -import { ShareProgress } from "../components/ShareProgress"; +const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress)); export function Home() { const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();