Fixed
This commit is contained in:
parent
ebbf987e95
commit
d487282fa5
35
CLAUDE.md
35
CLAUDE.md
@ -93,6 +93,41 @@ Checks:
|
||||
- If `SANDBOX_MODE=true` or `DISABLE_EXTERNAL_APIS=true`, payment intent flow
|
||||
stays local and does not call Stripe.
|
||||
|
||||
### Islands disappear after load (production / Ubuntu server)
|
||||
|
||||
Symptom:
|
||||
|
||||
- Island components (e.g. WelcomeBar, GuestHome) render briefly then vanish when
|
||||
the app is run from a production build on another machine (e.g. Ubuntu).
|
||||
|
||||
Cause:
|
||||
|
||||
- Multiple copies of Preact end up in the bundle/cache. Hydration then fails and
|
||||
Preact can tear down the island DOM. Common when the server has a different or
|
||||
stale Deno cache than the machine that built the app.
|
||||
|
||||
Checks and fix:
|
||||
|
||||
1. On the **deployment server** (Ubuntu), force a clean dependency install
|
||||
before building:
|
||||
|
||||
```bash
|
||||
deno install -r --allow-scripts
|
||||
deno task build
|
||||
deno task start
|
||||
```
|
||||
|
||||
2. Ensure the process **working directory is the project root** so that
|
||||
`_fresh/server.js` and `_fresh/static/` are found (e.g. in systemd set
|
||||
`WorkingDirectory=` to the app directory).
|
||||
|
||||
3. For reproducible builds across machines, consider re-enabling the lockfile
|
||||
in `deno.json` (`"lock": true`) and committing `deno.lock`, then using the
|
||||
same lock on the server.
|
||||
|
||||
4. Do not use `esm.sh` for Preact (this project already uses `npm:preact`);
|
||||
duplicate Preact from mixed sources is a common cause.
|
||||
|
||||
## 7. Change Discipline
|
||||
|
||||
- Keep docs and code aligned when behavior changes.
|
||||
|
||||
20
deno.json
20
deno.json
@ -1,4 +1,5 @@
|
||||
{
|
||||
"lock": false,
|
||||
"tasks": {
|
||||
"check": "deno fmt && deno fmt --check . && deno lint . && deno check",
|
||||
"dev": "vite",
|
||||
@ -8,13 +9,13 @@
|
||||
"e2e": "deno run -A npm:playwright test",
|
||||
"e2e:ui": "deno run -A npm:playwright test --ui",
|
||||
"e2e:report": "deno run -A npm:playwright show-report",
|
||||
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate",
|
||||
"db:migrate": "node ./node_modules/drizzle-kit/bin.cjs migrate",
|
||||
"db:push": "node ./node_modules/drizzle-kit/bin.cjs push",
|
||||
"db:studio": "node ./node_modules/drizzle-kit/bin.cjs studio",
|
||||
"db:generate": "deno run -A npm:drizzle-kit generate",
|
||||
"db:migrate": "deno run -A npm:drizzle-kit migrate",
|
||||
"db:push": "deno run -A npm:drizzle-kit push",
|
||||
"db:studio": "deno run -A npm:drizzle-kit studio",
|
||||
"test:auth-callback-db": "./tests/run-auth-callback-db-test.sh"
|
||||
},
|
||||
"lint": { "exclude": ["node_modules/", "_fresh/"] },
|
||||
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
|
||||
"exclude": ["**/_fresh/*"],
|
||||
"imports": {
|
||||
"fresh": "jsr:@fresh/core@^2.2.0",
|
||||
@ -34,7 +35,6 @@
|
||||
"denoPath": "https://deno.land/std@0.224.0/path/mod.ts",
|
||||
"denoFs": "https://deno.land/std@0.224.0/fs/mod.ts",
|
||||
"encoder": "jsr:@std/encoding/base64",
|
||||
"@vite-plugin-static-copy": "npm:vite-plugin-static-copy",
|
||||
"@GraphClient": "npm:@microsoft/microsoft-graph-client@3.0.0",
|
||||
"@IsomorphicFetch": "npm:isomorphic-fetch",
|
||||
"@openai": "https://deno.land/x/openai@v4.69.0/mod.ts",
|
||||
@ -75,8 +75,12 @@
|
||||
],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"test": { "exclude": ["node_modules/", "_fresh/"] },
|
||||
"fmt": { "exclude": ["node_modules/", "_fresh/"] },
|
||||
"test": {
|
||||
"exclude": ["node_modules/", "_fresh/"]
|
||||
},
|
||||
"fmt": {
|
||||
"exclude": ["node_modules/", "_fresh/"]
|
||||
},
|
||||
"nodeModulesDir": "auto",
|
||||
"unstable": ["kv"]
|
||||
}
|
||||
|
||||
@ -4,9 +4,15 @@ import type { OnboardingStatus } from "../services/onboardingService.ts";
|
||||
|
||||
interface MemberHomeProps {
|
||||
onboardingStatus?: OnboardingStatus | null;
|
||||
/** Server-provided calendar year for hydration-safe initial link (avoids server/client date mismatch). */
|
||||
serverCalendarYear?: number;
|
||||
/** Server-provided calendar month (1–12) for hydration-safe initial link. */
|
||||
serverCalendarMonth?: number;
|
||||
}
|
||||
|
||||
export default function MemberHome({ onboardingStatus }: MemberHomeProps) {
|
||||
export default function MemberHome(
|
||||
{ onboardingStatus, serverCalendarYear, serverCalendarMonth }: MemberHomeProps,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const lockRoute = localStorage.getItem("lock-route");
|
||||
if (lockRoute) {
|
||||
@ -55,9 +61,10 @@ export default function MemberHome({ onboardingStatus }: MemberHomeProps) {
|
||||
? { label: "Open Tenant Dashboard", href: "/tenant/dashboard" }
|
||||
: { label: "Continue Tenant Setup", href: "/tenant/dashboard" };
|
||||
|
||||
const now = new Date();
|
||||
const calendarHref = `/calendar/financial-calendar/${now.getFullYear()}/${
|
||||
(now.getMonth() + 1).toString().padStart(2, "0")
|
||||
const year = serverCalendarYear ?? new Date().getFullYear();
|
||||
const month = serverCalendarMonth ?? new Date().getMonth() + 1;
|
||||
const calendarHref = `/calendar/financial-calendar/${year}/${
|
||||
month.toString().padStart(2, "0")
|
||||
}?`;
|
||||
|
||||
return (
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
* Native-rendered dropdown content for <details> menu.
|
||||
*/
|
||||
export default function NavigationMenu(
|
||||
{ isLoggedIn, currentRoute, navigationGroups }: NavigationMenuProps,
|
||||
{ isLoggedIn, currentRoute, navigationGroups, serverCalendarPath }: NavigationMenuProps,
|
||||
) {
|
||||
function getItemStateClass(item: NavigationItem): string {
|
||||
if (item.disabled) return "navigation-menu-item--disabled";
|
||||
@ -73,6 +73,7 @@ export default function NavigationMenu(
|
||||
isLoggedIn,
|
||||
currentRoute,
|
||||
navigationGroups,
|
||||
serverCalendarPath,
|
||||
);
|
||||
|
||||
if (groups.length === 0) {
|
||||
|
||||
@ -16,7 +16,7 @@ function normalizeRoute(path: string): string {
|
||||
* Main navigation bar with user info, encryption status, and menu.
|
||||
*/
|
||||
export default function WelcomeBar(
|
||||
{ publicId, user_name, currentRoute, isEncrypted, stateId }: WelcomeBarProps,
|
||||
{ publicId, user_name, currentRoute, isEncrypted, stateId, serverCalendarPath }: WelcomeBarProps,
|
||||
) {
|
||||
const [activeRoute, setActiveRoute] = useState(normalizeRoute(currentRoute));
|
||||
const [state] = useState<WelcomeBarState>({
|
||||
@ -182,6 +182,7 @@ export default function WelcomeBar(
|
||||
<NavigationMenu
|
||||
isLoggedIn={hasValidUserId}
|
||||
currentRoute={activeRoute}
|
||||
serverCalendarPath={serverCalendarPath}
|
||||
/>
|
||||
</details>
|
||||
|
||||
|
||||
@ -11,6 +11,9 @@ function normalizePath(path: string): string {
|
||||
return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||
}
|
||||
|
||||
/** Placeholder replaced at runtime with server or client calendar path for hydration-safe nav. */
|
||||
export const CALENDAR_PATH_PLACEHOLDER = "__SERVER_CALENDAR_PATH__";
|
||||
|
||||
function getCurrentFinancialCalendarPath(date = new Date()): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@ -88,7 +91,7 @@ export const defaultNavigationGroups: NavigationGroup[] = [
|
||||
label: "App Switch",
|
||||
items: [
|
||||
{
|
||||
path: getCurrentFinancialCalendarPath(),
|
||||
path: CALENDAR_PATH_PLACEHOLDER,
|
||||
label: "Calendar",
|
||||
requiresAuth: true,
|
||||
matchPrefixes: ["/calendar/financial-calendar"],
|
||||
@ -110,7 +113,7 @@ export const defaultNavigationGroups: NavigationGroup[] = [
|
||||
label: "Calendar",
|
||||
items: [
|
||||
{
|
||||
path: getCurrentFinancialCalendarPath(),
|
||||
path: CALENDAR_PATH_PLACEHOLDER,
|
||||
label: "Financial Calendar",
|
||||
requiresAuth: true,
|
||||
matchPrefixes: ["/calendar/financial-calendar"],
|
||||
@ -201,17 +204,27 @@ const contextGroupOrder: Record<NavigationContext, string[]> = {
|
||||
|
||||
/**
|
||||
* Get navigation groups filtered by authentication status and current route context.
|
||||
* serverCalendarPath: use when provided so server and client render the same calendar links (avoids hydration mismatch).
|
||||
*/
|
||||
export function getNavigationGroups(
|
||||
isLoggedIn: boolean,
|
||||
currentRoute: string,
|
||||
customGroups?: NavigationGroup[],
|
||||
serverCalendarPath?: string,
|
||||
): NavigationGroup[] {
|
||||
const calendarPath = serverCalendarPath ?? getCurrentFinancialCalendarPath();
|
||||
const groups = customGroups || defaultNavigationGroups;
|
||||
const route = normalizePath(currentRoute);
|
||||
const context = resolveNavigationContext(route);
|
||||
const orderedGroupIds = contextGroupOrder[context];
|
||||
|
||||
const substituteCalendarPath = (items: NavigationItem[]): NavigationItem[] =>
|
||||
items.map((item) =>
|
||||
item.path === CALENDAR_PATH_PLACEHOLDER
|
||||
? { ...item, path: calendarPath }
|
||||
: item
|
||||
);
|
||||
|
||||
return groups
|
||||
.filter((group) => {
|
||||
if (customGroups) return true;
|
||||
@ -223,7 +236,7 @@ export function getNavigationGroups(
|
||||
})
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => {
|
||||
items: substituteCalendarPath(group.items).filter((item) => {
|
||||
if (item.requiresAuth && !isLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -9,6 +9,8 @@ export interface WelcomeBarProps {
|
||||
currentRoute: string;
|
||||
isEncrypted: boolean;
|
||||
stateId: string;
|
||||
/** Server calendar path for hydration-safe nav (layout should pass from server Date). */
|
||||
serverCalendarPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -68,6 +70,7 @@ export interface NavigationMenuProps {
|
||||
isLoggedIn: boolean;
|
||||
currentRoute: string;
|
||||
navigationGroups?: NavigationGroup[];
|
||||
serverCalendarPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,6 +22,9 @@ export default define.page(async function App(props: PageProps) {
|
||||
const publicId = stateData.user_id
|
||||
? await createPublicId(stateData.user_id)
|
||||
: "";
|
||||
const now = new Date();
|
||||
const serverCalendarPath =
|
||||
`/calendar/financial-calendar/${now.getFullYear()}/${(now.getMonth() + 1).toString().padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<html>
|
||||
@ -37,6 +40,7 @@ export default define.page(async function App(props: PageProps) {
|
||||
currentRoute={url.pathname}
|
||||
isEncrypted={stateData.user_encrypted}
|
||||
stateId={state.stateId}
|
||||
serverCalendarPath={serverCalendarPath}
|
||||
/>
|
||||
<div class="page-offset">
|
||||
<Component />
|
||||
|
||||
@ -3,7 +3,6 @@ import { define, State } from "../utils.ts";
|
||||
import GuestHome from "../islands/GuestHome.tsx";
|
||||
import MemberHome from "../islands/MemberHome.tsx";
|
||||
import { page } from "fresh";
|
||||
import { getYear } from "../helpers.ts";
|
||||
import { SessionCookie } from "./auth/callback.ts";
|
||||
import { createPublicId } from "../helpers.ts";
|
||||
import { getOnboardingStatus } from "../services/onboardingService.ts";
|
||||
@ -29,12 +28,16 @@ export const handler = define.handlers(async (ctx) => {
|
||||
const onboardingStatus = stateData.user_id
|
||||
? await getOnboardingStatus(stateData.user_id)
|
||||
: null;
|
||||
const now = new Date();
|
||||
return page({
|
||||
stateData,
|
||||
urlRoute: ctx.state.urlRoute,
|
||||
publicId,
|
||||
stateId: state.stateId,
|
||||
onboardingStatus,
|
||||
serverYear: now.getFullYear(),
|
||||
serverCalendarYear: now.getFullYear(),
|
||||
serverCalendarMonth: now.getMonth() + 1,
|
||||
});
|
||||
});
|
||||
|
||||
@ -47,6 +50,9 @@ export default define.page(
|
||||
publicId: string;
|
||||
stateId: string;
|
||||
onboardingStatus: Awaited<ReturnType<typeof getOnboardingStatus>>;
|
||||
serverYear: number;
|
||||
serverCalendarYear: number;
|
||||
serverCalendarMonth: number;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
@ -54,11 +60,17 @@ export default define.page(
|
||||
<main class="home-route">
|
||||
<div class="w-full flex-1">
|
||||
{data.stateData.user_id
|
||||
? <MemberHome onboardingStatus={data.onboardingStatus} />
|
||||
? (
|
||||
<MemberHome
|
||||
onboardingStatus={data.onboardingStatus}
|
||||
serverCalendarYear={data.serverCalendarYear}
|
||||
serverCalendarMonth={data.serverCalendarMonth}
|
||||
/>
|
||||
)
|
||||
: <GuestHome />}
|
||||
</div>
|
||||
<footer class="home-footer">
|
||||
© {getYear(new Date())} Cyber Hybrid Hub. All rights reserved.
|
||||
© {data.serverYear} Cyber Hybrid Hub. All rights reserved.
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
|
||||
@ -278,7 +278,7 @@ export default define.page(
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user