docs/superpowers/plans/2026-05-12-first-screen-lazy-heavy-deps.md
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.
web/src/components/map/types.ts
MapPoint interface used by parents that must not import Leaflet.web/src/components/map/LazyLocationPicker.tsx
React.lazy boundary and map-sized fallback for LocationPicker.web/src/main.tsx
web/src/router/index.tsx
web/vite.config.mts
web/src/components/map/LocationPicker.tsx
MapPoint values at the public component boundary.LatLng runtime use internal to the map implementation.web/src/components/map/index.ts
LocationPicker and map utility helpers from the barrel so non-map imports do not pull Leaflet into parent chunks.useReverseGeocoding because it has no Leaflet dependency.web/src/components/MemoEditor/types/insert-menu.ts
LatLng in editor state with MapPoint.web/src/components/MemoEditor/hooks/useLocation.ts
web/src/components/MemoEditor/Toolbar/InsertMenu.tsx
useReverseGeocoding directly from its file.web/src/components/MemoMetadata/Location/LocationDialog.tsx
LazyLocationPicker and MapPoint.web/src/components/MemoMetadata/Location/LocationDisplayView.tsx
LazyLocationPicker inside the popover.web/src/pages/UserProfile.tsx
UserMemoMap only when the map tab is active.web/src/components/MemoContent/MemoMarkdownRenderer.tsx
web/src/components/MemoContent/MermaidBlock.tsx
web/src/components/UserMemoMap/UserMemoMap.tsx
pnpm lint, pnpm build, and a production preview/browser network check.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
Step 1: Add a Leaflet-free map coordinate type
Create web/src/components/map/types.ts:
export interface MapPoint {
lat: number;
lng: number;
}
In web/src/components/MemoEditor/types/insert-menu.ts, replace the entire file with:
import type { MapPoint } from "@/components/map/types";
export interface LocationState {
placeholder: string;
position?: MapPoint;
latInput: string;
lngInput: string;
}
useLocationIn web/src/components/MemoEditor/hooks/useLocation.ts, remove import { LatLng } from "leaflet";, add a type import, and use plain coordinate objects:
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],
);
};
InsertMenuIn web/src/components/MemoEditor/Toolbar/InsertMenu.tsx:
Remove:
import { LatLng } from "leaflet";
Replace:
import { useReverseGeocoding } from "@/components/map";
with:
import { useReverseGeocoding } from "@/components/map/useReverseGeocoding";
Replace the geolocation success handler body:
handleLocationPositionChange(new LatLng(position.coords.latitude, position.coords.longitude));
with:
handleLocationPositionChange({ lat: position.coords.latitude, lng: position.coords.longitude });
Run:
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.
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
Step 1: Create lazy location picker wrapper
Create web/src/components/map/LazyLocationPicker.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>
);
};
LocationPicker public props to MapPointIn web/src/components/map/LocationPicker.tsx:
Add CSS and type imports near the top:
import "leaflet/dist/leaflet.css";
import type { MapPoint } from "./types";
Keep Leaflet runtime import:
import L, { LatLng } from "leaflet";
Add helper functions after imports:
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:
interface LocationMarkerProps {
position: LatLng | undefined;
onChange: (position: MapPoint) => void;
readonly?: boolean;
}
In LocationMarker, replace:
onChange(e.latlng);
with:
onChange(fromLatLng(e.latlng));
Update MapControlsProps:
interface MapControlsProps {
position: MapPoint | undefined;
}
Update LocationPickerProps:
interface LocationPickerProps {
readonly?: boolean;
latlng?: MapPoint;
onChange?: (position: MapPoint) => void;
className?: string;
}
Replace:
const DEFAULT_CENTER_LAT_LNG = new LatLng(48.8584, 2.2945);
with:
const DEFAULT_CENTER: MapPoint = { lat: 48.8584, lng: 2.2945 };
Inside LocationPicker, replace:
const position = latlng || DEFAULT_CENTER_LAT_LNG;
with:
const position = latlng || DEFAULT_CENTER;
const mapCenter = toLatLng(position);
const markerPosition = latlng ? toLatLng(latlng) : mapCenter;
Replace the MapContainer props and marker call:
<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>
Replace web/src/components/map/index.ts with:
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.
LocationDialogIn web/src/components/MemoMetadata/Location/LocationDialog.tsx:
Remove:
import type { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
Add:
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
import type { MapPoint } from "@/components/map/types";
Change the prop type:
onPositionChange: (position: MapPoint) => void;
Replace:
<LocationPicker className="h-full" latlng={position} onChange={onPositionChange} />
with:
{open && <LazyLocationPicker className="h-full" latlng={position} onChange={onPositionChange} />}
LocationDisplayViewIn web/src/components/MemoMetadata/Location/LocationDisplayView.tsx:
Remove:
import { LatLng } from "leaflet";
import { LocationPicker } from "@/components/map";
Add:
import { LazyLocationPicker } from "@/components/map/LazyLocationPicker";
Replace:
<LocationPicker latlng={new LatLng(location.latitude, location.longitude)} readonly={true} />
with:
{popoverOpen && <LazyLocationPicker latlng={{ lat: location.latitude, lng: location.longitude }} readonly={true} />}
Run:
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.
Files:
Modify: web/src/pages/UserProfile.tsx
Modify: web/src/components/UserMemoMap/UserMemoMap.tsx
Step 1: Move UserMemoMap behind React.lazy
In web/src/pages/UserProfile.tsx, replace the React import section:
import copy from "copy-to-clipboard";
import { ExternalLinkIcon, LayoutListIcon, type LucideIcon, MapIcon } from "lucide-react";
import { toast } from "react-hot-toast";
with:
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:
import UserMemoMap from "@/components/UserMemoMap";
Add after the type TabView = "memos" | "map"; line:
const UserMemoMap = lazy(() => import("@/components/UserMemoMap"));
Replace:
<div className="">
<UserMemoMap creator={user.name} className="h-[60dvh] sm:h-[500px] rounded-xl" />
</div>
with:
<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>
In web/src/components/UserMemoMap/UserMemoMap.tsx, add the Leaflet CSS import above marker cluster CSS:
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.
Run:
cd web && pnpm lint
Expected: PASS, or only unrelated pre-existing failures.
Files:
Modify: web/src/main.tsx
Modify: web/src/components/MemoContent/MemoMarkdownRenderer.tsx
Modify: web/src/components/MemoContent/MermaidBlock.tsx
Step 1: Remove feature CSS from app entry
In web/src/main.tsx, remove:
import "leaflet/dist/leaflet.css";
import "katex/dist/katex.min.css";
In web/src/components/MemoContent/MemoMarkdownRenderer.tsx, add this import with the existing dependency imports:
import "katex/dist/katex.min.css";
In web/src/components/MemoContent/MermaidBlock.tsx, remove:
import mermaid from "mermaid";
In web/src/components/MemoContent/MermaidBlock.tsx, remove the existing Mermaid initialization effect:
useEffect(() => {
mermaid.initialize({
startOnLoad: false,
theme: toMermaidTheme(currentTheme),
securityLevel: "strict",
fontFamily: "inherit",
suppressErrorRendering: true,
});
}, [currentTheme]);
Replace the existing render effect with:
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]);
Run:
cd web && pnpm lint
Expected: PASS, or only unrelated pre-existing failures.
Files:
Modify: web/src/router/index.tsx
Modify: web/vite.config.mts
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.
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.
Run:
cd web && pnpm lint && pnpm build
Expected: PASS.
Files:
No source edits expected unless verification finds a regression.
Step 1: Build production frontend
Run:
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.
Run:
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.
Run:
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.
/auth/signup network with browser toolingOpen:
http://127.0.0.1:4173/auth/signup
Expected initial document and asset requests do not include filenames containing:
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.
Use the running app or a local backend/dev setup to verify:
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.
Stop the preview command from Step 3 with Ctrl-C.
Files:
All source files modified by Tasks 1-4.
Step 1: Review final diff
Run:
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.
Run:
cd web && pnpm lint && pnpm build
Expected: PASS.
Run:
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.
MapPoint is the shared parent-facing coordinate type; LatLng remains internal to LocationPicker and map implementation files only.