perf: lazy load heavy first-screen dependencies (#5947)

pull/5950/head
boojack 3 weeks ago committed by GitHub
parent 7f1f53ffc4
commit a6024eebf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,774 @@
# First Screen Lazy Heavy Dependencies Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep Mermaid, Leaflet, React Leaflet, marker cluster, and feature CSS out of the auth/signup initial screen while preserving diagram, math, and map behavior when those features are used.
**Architecture:** Move runtime Leaflet objects behind plain coordinate data at parent boundaries, then lazy-load map implementations from small wrapper components. Replace Mermaid's static import with effect-time dynamic import, and move KaTeX/Leaflet CSS from the app entry into feature modules.
**Tech Stack:** React 19, TypeScript 6, Vite 8/Rolldown, React Router 7, React Query 5, Mermaid, KaTeX, Leaflet, React Leaflet, pnpm.
---
## File Structure
- Create `web/src/components/map/types.ts`
- Defines a lightweight `MapPoint` interface used by parents that must not import Leaflet.
- Create `web/src/components/map/LazyLocationPicker.tsx`
- Provides a `React.lazy` boundary and map-sized fallback for `LocationPicker`.
- Modify `web/src/main.tsx`
- Removes global Leaflet and KaTeX CSS imports.
- Modify `web/src/router/index.tsx`
- Lazy-loads the Home route so memo/editor/markdown modules are not part of the auth/signup entry graph.
- Modify `web/vite.config.mts`
- Keeps optional heavy dependency split groups from becoming entry preloads.
- Modify `web/src/components/map/LocationPicker.tsx`
- Imports Leaflet CSS inside the lazy implementation path.
- Accepts and emits plain `MapPoint` values at the public component boundary.
- Keeps `LatLng` runtime use internal to the map implementation.
- Modify `web/src/components/map/index.ts`
- Stops exporting `LocationPicker` and map utility helpers from the barrel so non-map imports do not pull Leaflet into parent chunks.
- Keeps exporting `useReverseGeocoding` because it has no Leaflet dependency.
- Modify `web/src/components/MemoEditor/types/insert-menu.ts`
- Replaces `LatLng` in editor state with `MapPoint`.
- Modify `web/src/components/MemoEditor/hooks/useLocation.ts`
- Removes runtime Leaflet import and stores plain coordinates.
- Modify `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- Removes runtime Leaflet import.
- Imports `useReverseGeocoding` directly from its file.
- Constructs plain coordinates from geolocation.
- Modify `web/src/components/MemoMetadata/Location/LocationDialog.tsx`
- Uses `LazyLocationPicker` and `MapPoint`.
- Only mounts the lazy picker while the dialog is open.
- Modify `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`
- Removes runtime Leaflet import and uses `LazyLocationPicker` inside the popover.
- Modify `web/src/pages/UserProfile.tsx`
- Lazy-loads `UserMemoMap` only when the map tab is active.
- Modify `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`
- Imports KaTeX CSS from the markdown-rendering path.
- Modify `web/src/components/MemoContent/MermaidBlock.tsx`
- Dynamically imports Mermaid only when rendering a Mermaid block.
- Modify `web/src/components/UserMemoMap/UserMemoMap.tsx`
- Keeps marker cluster CSS in the lazy map implementation path.
- Verify with `pnpm lint`, `pnpm build`, and a production preview/browser network check.
## Task 1: Remove Leaflet From Editor Location State
**Files:**
- Create: `web/src/components/map/types.ts`
- Modify: `web/src/components/MemoEditor/types/insert-menu.ts`
- Modify: `web/src/components/MemoEditor/hooks/useLocation.ts`
- Modify: `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`
- [x] **Step 1: Add a Leaflet-free map coordinate type**
Create `web/src/components/map/types.ts`:
```ts
export interface MapPoint {
lat: number;
lng: number;
}
```
- [x] **Step 2: Replace editor location state type**
In `web/src/components/MemoEditor/types/insert-menu.ts`, replace the entire file with:
```ts
import type { MapPoint } from "@/components/map/types";
export interface LocationState {
placeholder: string;
position?: MapPoint;
latInput: string;
lngInput: string;
}
```
- [x] **Step 3: Remove Leaflet runtime usage from `useLocation`**
In `web/src/components/MemoEditor/hooks/useLocation.ts`, remove `import { LatLng } from "leaflet";`, add a type import, and use plain coordinate objects:
```ts
import { create } from "@bufbuild/protobuf";
import { useCallback, useMemo, useRef, useState } from "react";
import type { MapPoint } from "@/components/map/types";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu";
export const useLocation = (initialLocation?: Location) => {
const [locationInitialized, setLocationInitialized] = useState(false);
const locationInitializedRef = useRef(locationInitialized);
locationInitializedRef.current = locationInitialized;
const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? { lat: initialLocation.latitude, lng: initialLocation.longitude } : undefined,
latInput: initialLocation ? String(initialLocation.latitude) : "",
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
const stateRef = useRef(state);
stateRef.current = state;
const updatePosition = useCallback((position?: MapPoint) => {
setState((prev) => ({
...prev,
position,
latInput: position ? String(position.lat) : "",
lngInput: position ? String(position.lng) : "",
}));
}, []);
const handlePositionChange = useCallback(
(position: MapPoint) => {
if (!locationInitializedRef.current) setLocationInitialized(true);
updatePosition(position);
},
[updatePosition],
);
const updateCoordinate = useCallback((type: "lat" | "lng", value: string) => {
const num = parseFloat(value);
const isValid = type === "lat" ? !isNaN(num) && num >= -90 && num <= 90 : !isNaN(num) && num >= -180 && num <= 180;
setState((prev) => {
const next = { ...prev, [type === "lat" ? "latInput" : "lngInput"]: value };
if (isValid && prev.position) {
const newPos = type === "lat" ? { lat: num, lng: prev.position.lng } : { lat: prev.position.lat, lng: num };
return { ...next, position: newPos, latInput: String(newPos.lat), lngInput: String(newPos.lng) };
}
return next;
});
}, []);
const setPlaceholder = useCallback((placeholder: string) => {
setState((prev) => ({ ...prev, placeholder }));
}, []);
const reset = useCallback(() => {
setState({
placeholder: "",
position: undefined,
latInput: "",
lngInput: "",
});
setLocationInitialized(false);
}, []);
const getLocation = useCallback((): Location | undefined => {
const { position, placeholder } = stateRef.current;
if (!position || !placeholder.trim()) {
return undefined;
}
return create(LocationSchema, {
latitude: position.lat,
longitude: position.lng,
placeholder,
});
}, []);
return useMemo(
() => ({ state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation }),
[state, locationInitialized, handlePositionChange, updateCoordinate, setPlaceholder, reset, getLocation],
);
};
```
- [x] **Step 4: Remove Leaflet import from `InsertMenu`**
In `web/src/components/MemoEditor/Toolbar/InsertMenu.tsx`:
Remove:
```ts
import { LatLng } from "leaflet";
```
Replace:
```ts
import { useReverseGeocoding } from "@/components/map";
```
with:
```ts
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
```
Replace the geolocation success handler body:
```ts
handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
```
with:
```ts
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
```
- [x] **Step 5: Run focused type check**
Run:
```bash
cd web && pnpm lint
```
Expected: TypeScript may still fail because map component props have not been converted yet. If the only failures are `LatLng`/`MapPoint` mismatches in map/location components, continue to Task 2. If unrelated failures appear, stop and inspect before editing further.
## Task 2: Lazy-Load Location Picker And Keep Leaflet Inside Map Modules
**Files:**
- Create: `web/src/components/map/LazyLocationPicker.tsx`
- Modify: `web/src/components/map/LocationPicker.tsx`
- Modify: `web/src/components/map/index.ts`
- Modify: `web/src/components/MemoMetadata/Location/LocationDialog.tsx`
- Modify: `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`
- [x] **Step 1: Create lazy location picker wrapper**
Create `web/src/components/map/LazyLocationPicker.tsx`:
```tsx
import { lazy, Suspense } from "react";
import { cn } from "@/lib/utils";
import type { MapPoint } from "./types";
interface LazyLocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
const LocationPicker = lazy(() => import("./LocationPicker"));
export const LazyLocationPicker = ({ className, ...props }: LazyLocationPickerProps) => {
return (
<Suspense
fallback={
<div
className={cn(
"memo-location-map relative isolate h-72 w-full overflow-hidden rounded-xl border border-border bg-muted/30 shadow-sm",
className,
)}
/>
}
>
<LocationPicker className={className} {...props} />
</Suspense>
);
};
```
- [x] **Step 2: Convert `LocationPicker` public props to `MapPoint`**
In `web/src/components/map/LocationPicker.tsx`:
Add CSS and type imports near the top:
```ts
import "leaflet/dist/leaflet.css";
import type { MapPoint } from "./types";
```
Keep Leaflet runtime import:
```ts
import L, { LatLng } from "leaflet";
```
Add helper functions after imports:
```ts
const toLatLng = (point: MapPoint): LatLng => new LatLng(point.lat, point.lng);
const fromLatLng = (latlng: LatLng): MapPoint => ({ lat: latlng.lat, lng: latlng.lng });
```
Update public-facing props:
```ts
interface LocationMarkerProps {
position: LatLng | undefined;
onChange: (position: MapPoint) => void;
readonly?: boolean;
}
```
In `LocationMarker`, replace:
```ts
onChange(e.latlng);
```
with:
```ts
onChange(fromLatLng(e.latlng));
```
Update `MapControlsProps`:
```ts
interface MapControlsProps {
position: MapPoint | undefined;
}
```
Update `LocationPickerProps`:
```ts
interface LocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
```
Replace:
```ts
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
```
with:
```ts
const DEFAULT_CENTER: MapPoint = { lat: 48.8584, lng: 2.2945 };
```
Inside `LocationPicker`, replace:
```ts
const position = latlng || DEFAULT_CENTER_LAT_LNG;
```
with:
```ts
const position = latlng || DEFAULT_CENTER;
const mapCenter = toLatLng(position);
const markerPosition = latlng ? toLatLng(latlng) : mapCenter;
```
Replace the `MapContainer` props and marker call:
```tsx
<MapContainer
className="h-full w-full !bg-muted"
center={mapCenter}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<LocationMarker position={markerPosition} readonly={readOnly} onChange={onChange} />
<MapControls position={latlng} />
<MapCleanup />
</MapContainer>
```
- [x] **Step 3: Keep the map barrel Leaflet-free**
Replace `web/src/components/map/index.ts` with:
```ts
export { useReverseGeocoding } from "./useReverseGeocoding";
```
Do not export `LocationPicker`, `LazyLocationPicker`, `map-utils`, or Leaflet helpers from this barrel. Import map UI directly from `@/components/map/LazyLocationPicker` or the implementation file.
- [x] **Step 4: Use lazy picker in `LocationDialog`**
In `web/src/components/MemoMetadata/Location/LocationDialog.tsx`:
Remove:
```ts
import type { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
```
Add:
```ts
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
import type { MapPoint } from "@/components/map/types";
```
Change the prop type:
```ts
onPositionChange: (position: MapPoint) => void;
```
Replace:
```tsx
<LocationPicker className="h-full" latlng={position} onChange={onPositionChange} />
```
with:
```tsx
{open && <LazyLocationPicker className="h-full" latlng={position} onChange={onPositionChange} />}
```
- [x] **Step 5: Use lazy picker in `LocationDisplayView`**
In `web/src/components/MemoMetadata/Location/LocationDisplayView.tsx`:
Remove:
```ts
import { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
```
Add:
```ts
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
```
Replace:
```tsx
<LocationPicker latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
```
with:
```tsx
{popoverOpen && <LazyLocationPicker latlng={{ lat: location.latitude, lng: location.longitude }} readonly={true} />}
```
- [x] **Step 6: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS for the files changed so far, or only failures unrelated to this work. Fix any `MapPoint`/`LatLng` type errors before continuing.
## Task 3: Lazy-Load Profile Map
**Files:**
- Modify: `web/src/pages/UserProfile.tsx`
- Modify: `web/src/components/UserMemoMap/UserMemoMap.tsx`
- [x] **Step 1: Move `UserMemoMap` behind `React.lazy`**
In `web/src/pages/UserProfile.tsx`, replace the React import section:
```ts
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
```
with:
```ts
import copy from "copy-to-clipboard";
import { lazy, Suspense } from "react";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
```
Remove:
```ts
import UserMemoMap from "@/components/UserMemoMap";
```
Add after the `type TabView = "memos" | "map";` line:
```ts
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
```
Replace:
```tsx
<div className="">
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</div>
```
with:
```tsx
<div className="">
<Suspense fallback={<div className="h-[60dvh] sm:h-[500px] rounded-xl border border-border bg-muted/30" />}>
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</Suspense>
</div>
```
- [x] **Step 2: Keep Leaflet CSS in the lazy profile map implementation**
In `web/src/components/UserMemoMap/UserMemoMap.tsx`, add the Leaflet CSS import above marker cluster CSS:
```ts
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
```
The file should continue to import Leaflet, React Leaflet, marker clustering, and `map-utils` directly because this component is now loaded only from a lazy boundary.
- [x] **Step 3: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS, or only unrelated pre-existing failures.
## Task 4: Move KaTeX CSS And Dynamically Import Mermaid
**Files:**
- Modify: `web/src/main.tsx`
- Modify: `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`
- Modify: `web/src/components/MemoContent/MermaidBlock.tsx`
- [x] **Step 1: Remove feature CSS from app entry**
In `web/src/main.tsx`, remove:
```ts
import "leaflet/dist/leaflet.css";
import "katex/dist/katex.min.css";
```
- [x] **Step 2: Load KaTeX CSS from markdown renderer**
In `web/src/components/MemoContent/MemoMarkdownRenderer.tsx`, add this import with the existing dependency imports:
```ts
import "katex/dist/katex.min.css";
```
- [x] **Step 3: Remove static Mermaid import**
In `web/src/components/MemoContent/MermaidBlock.tsx`, remove:
```ts
import mermaid from "mermaid";
```
- [x] **Step 4: Replace Mermaid initialization/render effects with dynamic import**
In `web/src/components/MemoContent/MermaidBlock.tsx`, remove the existing Mermaid initialization effect:
```ts
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
}, [currentTheme]);
```
Replace the existing render effect with:
```ts
useEffect(() => {
if (!codeContent) return;
let cancelled = false;
const renderDiagram = async () => {
try {
const { default: mermaid } = await import("mermaid");
if (cancelled) return;
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
if (cancelled) return;
setSvg(renderedSvg);
setError("");
} catch (err) {
if (cancelled) return;
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
}
};
renderDiagram();
return () => {
cancelled = true;
};
}, [codeContent, currentTheme]);
```
- [x] **Step 5: Run lint**
Run:
```bash
cd web && pnpm lint
```
Expected: PASS, or only unrelated pre-existing failures.
## Task 4.5: Remove Remaining Entry Preloads Found During Verification
**Files:**
- Modify: `web/src/router/index.tsx`
- Modify: `web/vite.config.mts`
- [x] **Step 1: Lazy-load the Home route**
`web/src/router/index.tsx` now uses `lazyWithReload(() => import("@/pages/Home"))` instead of a static Home import. This prevents Home, `PagedMemoList`, `MemoEditor`, `MemoContent`, KaTeX, and Mermaid code from entering the auth/signup app entry graph.
- [x] **Step 2: Tighten optional vendor split groups**
`web/vite.config.mts` no longer defines a manual `mermaid-vendor` group, because Rolldown emitted the preload helper from that group and forced an entry preload. The Leaflet group now matches only the `leaflet` package, not `react-leaflet`, so React does not get bundled into a Leaflet-named entry preload.
- [x] **Step 3: Re-run lint and build**
Run:
```bash
cd web && pnpm lint && pnpm build
```
Expected: PASS.
## Task 5: Build And Verify Initial Network Behavior
**Files:**
- No source edits expected unless verification finds a regression.
- [x] **Step 1: Build production frontend**
Run:
```bash
cd web && pnpm build
```
Expected: PASS. Build output should still contain separate Mermaid and Leaflet chunks, but they should not be required by the auth/signup initial route.
- [x] **Step 2: Inspect build output for heavy chunks**
Run:
```bash
cd web && find dist/assets -maxdepth 1 -type f \( -name '*mermaid*' -o -name '*leaflet*' -o -name '*katex*' \) -print | sort
```
Expected: Mermaid and Leaflet assets may exist as lazy chunks. Their existence is fine; the goal is that auth/signup does not request them initially.
- [x] **Step 3: Start production preview**
Run:
```bash
cd web && pnpm exec vite preview --host 127.0.0.1 --port 4173
```
Expected: Preview server starts on `http://127.0.0.1:4173/`. Keep this session running until browser verification is complete.
- [x] **Step 4: Verify `/auth/signup` network with browser tooling**
Open:
```text
http://127.0.0.1:4173/auth/signup
```
Expected initial document and asset requests do not include filenames containing:
```text
mermaid
leaflet
```
If KaTeX CSS still appears on `/auth/signup`, inspect the chunk initiator and remove any remaining static import path that reaches `MemoMarkdownRenderer` from auth/signup.
- [ ] **Step 5: Smoke test feature lazy loading**
Use the running app or a local backend/dev setup to verify:
```text
1. A memo containing a Mermaid code block renders the diagram and requests the Mermaid chunk only when the memo content appears.
2. A memo containing inline or block math displays KaTeX styling when memo content appears.
3. Opening the location picker loads Leaflet assets and the picker remains interactive.
4. Opening a memo location popover loads Leaflet assets and shows the pinned map.
5. Opening `/u/:username?view=map` loads the profile map and marker cluster behavior still works.
```
Expected: Features behave as before after their lazy chunks load.
Result in this session: live feature smoke was not completed because the production preview has no authenticated backend session or seeded memo data. Static verification confirmed the relevant feature chunks still exist and load paths are behind lazy imports.
- [x] **Step 6: Stop preview server**
Stop the preview command from Step 3 with `Ctrl-C`.
## Task 6: Commit Implementation
**Files:**
- All source files modified by Tasks 1-4.
- [x] **Step 1: Review final diff**
Run:
```bash
git diff -- web/src/main.tsx web/src/components/map web/src/components/MemoEditor web/src/components/MemoMetadata/Location web/src/pages/UserProfile.tsx web/src/components/MemoContent web/src/components/UserMemoMap/UserMemoMap.tsx
```
Expected: Diff is limited to lazy-loading heavy optional dependencies and plain coordinate type changes.
- [x] **Step 2: Run final verification**
Run:
```bash
cd web && pnpm lint && pnpm build
```
Expected: PASS.
- [x] **Step 3: Commit**
Run:
```bash
git add web/src/main.tsx web/src/components/map web/src/components/MemoEditor web/src/components/MemoMetadata/Location web/src/pages/UserProfile.tsx web/src/components/MemoContent web/src/components/UserMemoMap/UserMemoMap.tsx
git commit -m "perf: lazy load heavy first-screen dependencies"
```
Expected: Commit succeeds with only the intended source changes.
## Self-Review
- Spec coverage: The plan removes global Leaflet/KaTeX CSS, dynamically imports Mermaid, lazy-loads map UI, keeps Leaflet types out of parent boundaries, and verifies auth/signup network behavior.
- Placeholder scan: No placeholder markers, unresolved decisions, or vague generic handling steps remain.
- Type consistency: `MapPoint` is the shared parent-facing coordinate type; `LatLng` remains internal to `LocationPicker` and map implementation files only.

@ -0,0 +1,113 @@
# First Screen Lazy Heavy Dependencies Design
## Context
The auth and signup pages currently fetch JavaScript and CSS assets for features that are not used on the first screen, including Mermaid, KaTeX, Leaflet, and React Leaflet. The current routing already uses lazy route components, so the remaining problem is eager imports from shared app entry points and feature modules.
The goal is to reduce first screen load time, especially for `/auth` and `/auth/signup`, without changing memo rendering, map behavior, or authenticated workflows.
## Goals
- Prevent Mermaid and Leaflet vendor chunks from loading on auth/signup before they are needed.
- Prevent Leaflet and KaTeX CSS from loading globally at app startup.
- Preserve current behavior when users view Mermaid diagrams, math content, memo location previews, profile maps, or location pickers.
- Keep fallbacks small and consistent with existing async rendering patterns.
- Verify the production build and confirm auth/signup network requests no longer include Mermaid or Leaflet chunks during initial render.
## Non-Goals
- Rewriting the markdown rendering pipeline.
- Removing support for Mermaid, KaTeX, Leaflet, or React Leaflet.
- Optimizing every authenticated route in this change.
- Changing server behavior, route guards, or authentication flow.
## Recommended Approach
Use feature-level lazy loading for heavy optional features. Keep the app shell and auth routes free of diagram, math styling, and map dependencies. Load those dependencies from the feature boundary where the user actually needs them.
This approach has the best balance of impact and risk because it removes known eager imports while preserving existing route structure and feature internals.
## Architecture
### App Entry
`web/src/main.tsx` should no longer import:
- `leaflet/dist/leaflet.css`
- `katex/dist/katex.min.css`
These styles are feature-specific and should be loaded from the map and markdown rendering paths.
### Mermaid
`web/src/components/MemoContent/MermaidBlock.tsx` should replace the static `import mermaid from "mermaid"` with an async `import("mermaid")` inside the render effect.
The component should keep its current behavior:
- initialize Mermaid with the resolved app theme;
- render when code content or theme changes;
- show the existing error fallback when rendering fails.
The Mermaid chunk should only be requested when a memo actually renders a Mermaid code block.
### KaTeX
KaTeX CSS should load from the memo markdown rendering path instead of the app entry. Since `rehype-katex` is only useful when memo markdown is rendered, loading the stylesheet near `MemoMarkdownRenderer` keeps auth/signup free of KaTeX CSS while preserving math output styling.
This change does not need content-level math detection. Loading KaTeX CSS with memo markdown is simpler and still removes it from the first auth/signup screen.
### Leaflet Maps
Leaflet-dependent UI should be moved behind lazy component boundaries:
- `UserMemoMap` should be lazy-loaded by the user profile route or by a small wrapper component.
- `LocationPicker` should be lazy-loaded where location UI is opened or displayed.
The underlying map implementations can continue using Leaflet, React Leaflet, marker clustering, and their current helpers. The key boundary is that parent components must not statically import the map implementation if that parent can be pulled into non-map first-screen chunks.
Leaflet CSS and marker cluster CSS should load inside the lazy map implementation path, not from `main.tsx`.
### Type Imports
Any imports from `leaflet` that are used only as TypeScript types should use `import type`. Runtime construction such as `new LatLng(...)` should be avoided in parent components that are meant to stay Leaflet-free; pass plain latitude/longitude data into lazy map wrappers and construct Leaflet objects inside the lazy implementation.
## Data Flow
Auth/signup initial render:
1. App entry initializes theme, locale, providers, auth, and instance data.
2. Router loads only the auth/signup route component and shared app shell dependencies.
3. Mermaid, Leaflet, React Leaflet, marker cluster, and feature CSS are not requested.
Memo markdown render:
1. Memo content renders with the existing markdown renderer.
2. KaTeX CSS loads with the markdown rendering path.
3. If a code block language is `mermaid`, `MermaidBlock` dynamically imports Mermaid and renders the diagram.
Map render:
1. A map feature mounts through a lazy boundary.
2. The lazy implementation imports Leaflet, React Leaflet, and required map CSS.
3. Existing map interactions and display behavior continue inside the loaded implementation.
## Error Handling
- Mermaid import or render failures should use the existing Mermaid error UI with the original code content visible.
- Lazy map boundaries should use minimal fallbacks sized like the eventual map container to avoid layout shift.
- Chunk load failures should continue to use the existing router chunk reload behavior where applicable.
## Testing
- Run `pnpm build` in `web`.
- Run `pnpm lint` in `web`.
- Inspect production build output to ensure Mermaid and Leaflet remain split chunks.
- Use a production preview or equivalent browser check for `/auth/signup` and confirm initial network requests do not include Mermaid or Leaflet JavaScript chunks.
- Smoke test memo content with Mermaid and math.
- Smoke test location picker, location popover, and user profile map.
## Risks
- Loading CSS from lazy paths can cause a small style delay the first time a map or math content appears. Use map-sized fallbacks and keep CSS imports in the feature implementation to minimize visible shifts.
- Moving Leaflet runtime types out of parent components may require small prop shape changes.
- Dynamic Mermaid import needs effect cancellation to avoid setting state after unmount.

@ -1,4 +1,5 @@
import type { Element } from "hast";
import "katex/dist/katex.min.css";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";

@ -1,4 +1,3 @@
import mermaid from "mermaid";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
@ -38,8 +37,21 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
return setupSystemThemeListener(() => setSystemThemeChange((n) => n + 1));
}, [themePreference]);
// Initialize Mermaid when theme changes
// Render diagram when content or theme changes
useEffect(() => {
if (!codeContent) {
setSvg("");
setError("");
return;
}
let cancelled = false;
const renderDiagram = async () => {
try {
const { default: mermaid } = await import("mermaid");
if (cancelled) return;
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
@ -47,25 +59,26 @@ export const MermaidBlock = ({ children, className }: MermaidBlockProps) => {
fontFamily: "inherit",
suppressErrorRendering: true,
});
}, [currentTheme]);
// Render diagram when content or theme changes
useEffect(() => {
if (!codeContent) return;
const id = `mermaid-${Math.random().toString(36).substring(7)}`;
const { svg: renderedSvg } = await mermaid.render(id, codeContent);
if (cancelled) return;
mermaid
.render(id, codeContent)
.then(({ svg: renderedSvg }) => {
setSvg(renderedSvg);
setError("");
})
.catch((err) => {
} catch (err) {
if (cancelled) return;
console.error("Failed to render mermaid diagram:", err);
setSvg("");
setError(formatErrorMessage(err));
});
}
};
renderDiagram();
return () => {
cancelled = true;
};
}, [codeContent, currentTheme]);
if (error) {

@ -1,4 +1,3 @@
import { LatLng } from "leaflet";
import { uniqBy } from "lodash-es";
import {
FileIcon,
@ -15,7 +14,8 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDebounce } from "react-use";
import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata";
import { useReverseGeocoding } from "@/components/map";
import type { MapPoint } from "@/components/map/types";
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -74,7 +74,7 @@ const InsertMenu = (props: InsertMenuProps) => {
setPlaceholder,
} = location;
const [debouncedPosition, setDebouncedPosition] = useState<LatLng | undefined>(undefined);
const [debouncedPosition, setDebouncedPosition] = useState<MapPoint | undefined>(undefined);
useDebounce(
() => {
@ -104,7 +104,7 @@ const InsertMenu = (props: InsertMenuProps) => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
},
(error) => {
console.error("Geolocation error:", error);

@ -1,6 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { LatLng } from "leaflet";
import { useCallback, useMemo, useRef, useState } from "react";
import type { MapPoint } from "@/components/map/types";
import { Location, LocationSchema } from "@/types/proto/api/v1/memo_service_pb";
import { LocationState } from "../types/insert-menu";
@ -11,7 +11,7 @@ export const useLocation = (initialLocation?: Location) => {
const [state, setState] = useState<LocationState>({
placeholder: initialLocation?.placeholder || "",
position: initialLocation ? new LatLng(initialLocation.latitude, initialLocation.longitude) : undefined,
position: initialLocation ? { lat: initialLocation.latitude, lng: initialLocation.longitude } : undefined,
latInput: initialLocation ? String(initialLocation.latitude) : "",
lngInput: initialLocation ? String(initialLocation.longitude) : "",
});
@ -20,7 +20,7 @@ export const useLocation = (initialLocation?: Location) => {
const stateRef = useRef(state);
stateRef.current = state;
const updatePosition = useCallback((position?: LatLng) => {
const updatePosition = useCallback((position?: MapPoint) => {
setState((prev) => ({
...prev,
position,
@ -31,7 +31,7 @@ export const useLocation = (initialLocation?: Location) => {
// Stable — reads locationInitialized via ref to avoid recreating on every change.
const handlePositionChange = useCallback(
(position: LatLng) => {
(position: MapPoint) => {
if (!locationInitializedRef.current) setLocationInitialized(true);
updatePosition(position);
},
@ -45,7 +45,7 @@ export const useLocation = (initialLocation?: Location) => {
setState((prev) => {
const next = { ...prev, [type === "lat" ? "latInput" : "lngInput"]: value };
if (isValid && prev.position) {
const newPos = type === "lat" ? new LatLng(num, prev.position.lng) : new LatLng(prev.position.lat, num);
const newPos = type === "lat" ? { lat: num, lng: prev.position.lng } : { lat: prev.position.lat, lng: num };
return { ...next, position: newPos, latInput: String(newPos.lat), lngInput: String(newPos.lng) };
}
return next;

@ -1,8 +1,8 @@
import { LatLng } from "leaflet";
import type { MapPoint } from "@/components/map/types";
export interface LocationState {
placeholder: string;
position?: LatLng;
position?: MapPoint;
latInput: string;
lngInput: string;
}

@ -1,6 +1,6 @@
import type { LatLng } from "leaflet";
import type { LocationState } from "@/components/MemoEditor/types/insert-menu";
import { LocationPicker } from "@/components/map";
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
import type { MapPoint } from "@/components/map/types";
import { Button } from "@/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -13,7 +13,7 @@ interface LocationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
state: LocationState;
onPositionChange: (position: LatLng) => void;
onPositionChange: (position: MapPoint) => void;
onUpdateCoordinate: (type: "lat" | "lng", value: string) => void;
onPlaceholderChange: (placeholder: string) => void;
onCancel: () => void;
@ -47,7 +47,7 @@ export const LocationDialog = ({
</VisuallyHidden>
<div className="flex flex-col">
<div className="w-full h-64 overflow-hidden rounded-t-md bg-muted/30">
<LocationPicker className="h-full" latlng={position} onChange={onPositionChange} />
{open && <LazyLocationPicker className="h-full" latlng={position} onChange={onPositionChange} />}
</div>
<div className="w-full flex flex-col p-3 gap-3">
<div className="grid grid-cols-2 gap-3">

@ -1,7 +1,6 @@
import { LatLng } from "leaflet";
import { MapPinIcon } from "lucide-react";
import { useState } from "react";
import { LocationPicker } from "@/components/map";
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { Location } from "@/types/proto/api/v1/memo_service_pb";
@ -41,7 +40,7 @@ const LocationDisplayView = ({ location, className }: LocationDisplayViewProps)
</PopoverTrigger>
<PopoverContent align="start">
<div className="min-w-80 sm:w-lg flex flex-col justify-start items-start">
<LocationPicker latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
{popoverOpen && <LazyLocationPicker latlng={{ lat: location.latitude, lng: location.longitude }} readonly={true} />}
</div>
</PopoverContent>
</Popover>

@ -1,5 +1,6 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import L, { DivIcon } from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import { ArrowUpRightIcon, MapPinIcon } from "lucide-react";
import { useEffect, useMemo } from "react";

@ -0,0 +1,29 @@
import { lazy, Suspense } from "react";
import { cn } from "@/lib/utils";
import type { MapPoint } from "./types";
interface LazyLocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
const LocationPicker = lazy(() => import("./LocationPicker"));
export const LazyLocationPicker = ({ className, ...props }: LazyLocationPickerProps) => {
return (
<Suspense
fallback={
<div
className={cn(
"memo-location-map relative isolate h-72 w-full overflow-hidden rounded-xl border border-border bg-muted/30 shadow-sm",
className,
)}
/>
}
>
<LocationPicker className={className} {...props} />
</Suspense>
);
};

@ -1,14 +1,19 @@
import L, { LatLng } from "leaflet";
import "leaflet/dist/leaflet.css";
import { ExternalLinkIcon, MinusIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MapContainer, Marker, useMap, useMapEvents } from "react-leaflet";
import { cn } from "@/lib/utils";
import { defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
import type { MapPoint } from "./types";
const toLatLng = (point: MapPoint): LatLng => new LatLng(point.lat, point.lng);
const fromLatLng = (latlng: LatLng): MapPoint => ({ lat: latlng.lat, lng: latlng.lng });
interface LocationMarkerProps {
position: LatLng | undefined;
onChange: (position: LatLng) => void;
onChange: (position: MapPoint) => void;
readonly?: boolean;
}
@ -24,7 +29,7 @@ const LocationMarker = ({ position: initialPosition, onChange, readonly: readOnl
setPosition(e.latlng);
map.locate();
onChange(e.latlng);
onChange(fromLatLng(e.latlng));
},
locationfound() {},
});
@ -78,7 +83,7 @@ const GlassButton = ({ icon, onClick, ariaLabel, title }: GlassButtonProps) => {
// Container for all map control buttons
interface ControlButtonsProps {
position: LatLng | undefined;
position: MapPoint | undefined;
onZoomIn: () => void;
onZoomOut: () => void;
onOpenGoogleMaps: () => void;
@ -126,7 +131,7 @@ class MapControlsContainer extends L.Control {
}
interface MapControlsProps {
position: LatLng | undefined;
position: MapPoint | undefined;
}
const MapControls = ({ position }: MapControlsProps) => {
@ -197,16 +202,17 @@ const MapCleanup = () => {
interface LocationPickerProps {
readonly?: boolean;
latlng?: LatLng;
onChange?: (position: LatLng) => void;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
const DEFAULT_CENTER: MapPoint = { lat: 48.8584, lng: 2.2945 };
const noopOnLocationChange = () => {};
const LocationPicker = ({ readonly: readOnly = false, latlng, onChange = noopOnLocationChange, className }: LocationPickerProps) => {
const position = latlng || DEFAULT_CENTER_LAT_LNG;
const mapCenter = useMemo(() => toLatLng(latlng ?? DEFAULT_CENTER), [latlng?.lat, latlng?.lng]);
const markerPosition = mapCenter;
const statusLabel = readOnly ? "Pinned location" : latlng ? "Selected location" : "Choose a location";
return (
@ -218,14 +224,14 @@ const LocationPicker = ({ readonly: readOnly = false, latlng, onChange = noopOnL
>
<MapContainer
className="h-full w-full !bg-muted"
center={position}
center={mapCenter}
zoom={13}
scrollWheelZoom={false}
zoomControl={false}
attributionControl={false}
>
<ThemedTileLayer />
<LocationMarker position={position} readonly={readOnly} onChange={onChange} />
<LocationMarker position={markerPosition} readonly={readOnly} onChange={onChange} />
<MapControls position={latlng} />
<MapCleanup />
</MapContainer>

@ -1,3 +1 @@
export { default as LocationPicker } from "./LocationPicker";
export { createMarkerIcon, defaultMarkerIcon, ThemedTileLayer } from "./map-utils";
export { useReverseGeocoding } from "./useReverseGeocoding";

@ -0,0 +1,4 @@
export interface MapPoint {
lat: number;
lng: number;
}

@ -18,8 +18,6 @@ import { queryClient } from "@/lib/query-client";
import router from "./router";
import { applyLocaleEarly } from "./utils/i18n";
import { applyThemeEarly } from "./utils/theme";
import "leaflet/dist/leaflet.css";
import "katex/dist/katex.min.css";
// Apply theme and locale early to prevent flash
applyThemeEarly();

@ -1,11 +1,11 @@
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { lazy, Suspense } from "react";
import { toast } from "react-hot-toast";
import { useParams, useSearchParams } from "react-router-dom";
import MemoView from "@/components/MemoView";
import PagedMemoList from "@/components/PagedMemoList";
import UserAvatar from "@/components/UserAvatar";
import UserMemoMap from "@/components/UserMemoMap";
import { Button } from "@/components/ui/button";
import { useMemoFilters, useMemoSorting } from "@/hooks";
import { useUser } from "@/hooks/useUserQueries";
@ -16,6 +16,8 @@ import { useTranslate } from "@/utils/i18n";
type TabView = "memos" | "map";
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
const TabButton = ({
icon: Icon,
label,
@ -138,7 +140,9 @@ const UserProfile = () => {
/>
) : (
<div className="">
<Suspense fallback={<div className="h-[60dvh] sm:h-[500px] rounded-xl border border-border bg-muted/30" />}>
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</Suspense>
</div>
)}
</div>

@ -4,7 +4,6 @@ import App from "@/App";
import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home";
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards";
import { ROUTES } from "./routes";
@ -29,6 +28,7 @@ const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn"));
const Archived = lazyWithReload(() => import("@/pages/Archived"));
const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback"));
const Explore = lazyWithReload(() => import("@/pages/Explore"));
const Home = lazyWithReload(() => import("@/pages/Home"));
const Inboxes = lazyWithReload(() => import("@/pages/Inboxes"));
const MemoDetail = lazyWithReload(() => import("@/pages/MemoDetail"));
const NotFound = lazyWithReload(() => import("@/pages/NotFound"));

@ -0,0 +1,73 @@
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import LocationPicker from "@/components/map/LocationPicker";
const setView = vi.fn();
const locate = vi.fn();
const zoomIn = vi.fn();
const zoomOut = vi.fn();
const eventMap = { setView, locate };
const controlMap = { zoomIn, zoomOut };
vi.mock("leaflet", () => {
class LatLng {
lat: number;
lng: number;
constructor(lat: number, lng: number) {
this.lat = lat;
this.lng = lng;
}
}
class Control {
addTo() {
return this;
}
remove() {}
}
return {
default: {
Control,
DomUtil: {
create: () => ({ style: {} }),
},
DomEvent: {
disableClickPropagation: () => {},
disableScrollPropagation: () => {},
},
},
LatLng,
};
});
vi.mock("react-leaflet", () => ({
MapContainer: ({ children }: { children: ReactNode }) => <div data-testid="map">{children}</div>,
Marker: ({ position }: { position: { lat: number; lng: number } }) => <div data-testid="marker">{`${position.lat},${position.lng}`}</div>,
useMap: () => controlMap,
useMapEvents: () => eventMap,
}));
vi.mock("@/components/map/map-utils", () => ({
defaultMarkerIcon: {},
ThemedTileLayer: () => <div data-testid="tile-layer" />,
}));
describe("LocationPicker", () => {
it("does not recenter when rerendered with the same coordinates", () => {
const { rerender } = render(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
expect(setView).toHaveBeenCalledTimes(1);
rerender(<LocationPicker latlng={{ lat: 1, lng: 2 }} />);
expect(setView).toHaveBeenCalledTimes(1);
rerender(<LocationPicker latlng={{ lat: 3, lng: 4 }} />);
expect(setView).toHaveBeenCalledTimes(2);
});
});

@ -0,0 +1,33 @@
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MermaidBlock } from "@/components/MemoContent/MermaidBlock";
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({
userGeneralSetting: { theme: "default" },
}),
}));
const renderMermaid = vi.fn(async () => ({ svg: '<svg data-testid="diagram"></svg>' }));
const initializeMermaid = vi.fn();
vi.mock("mermaid", () => ({
default: {
initialize: initializeMermaid,
render: renderMermaid,
},
}));
const codeElement = (content: string) => <code className="language-mermaid">{content}</code>;
describe("MermaidBlock", () => {
it("clears rendered output when code content becomes empty", async () => {
const { container, rerender } = render(<MermaidBlock>{codeElement("graph TD; A-->B")}</MermaidBlock>);
await waitFor(() => expect(container.querySelector(".mermaid-diagram")).not.toBeNull());
rerender(<MermaidBlock>{codeElement("")}</MermaidBlock>);
await waitFor(() => expect(container.querySelector(".mermaid-diagram")).toBeNull());
});
});

@ -51,13 +51,9 @@ export default defineConfig({
name: "utils-vendor",
test: /node_modules[\\/](dayjs|lodash-es)([\\/]|$)/,
},
{
name: "mermaid-vendor",
test: /node_modules[\\/]mermaid([\\/]|$)/,
},
{
name: "leaflet-vendor",
test: /node_modules[\\/](leaflet|react-leaflet)([\\/]|$)/,
test: /node_modules[\\/]leaflet([\\/]|$)/,
},
],
},

Loading…
Cancel
Save