preload flutter

This commit is contained in:
2026-03-08 09:36:43 +01:00
parent addd78d057
commit c39dcb6164
2 changed files with 142 additions and 84 deletions

View File

@@ -28,60 +28,52 @@ declare global {
} }
} }
type FlutterViewProps = { // ---------------------------------------------------------------------------
state: FlutterTimelineState; // Module-level singleton state
onEvent: (event: FlutterEvent) => void; // ---------------------------------------------------------------------------
className?: string;
height?: number;
};
export function FlutterView({ let flutterContainer: HTMLDivElement | null = null;
state, let engineReady = false;
onEvent, let initPromise: Promise<void> | null = null;
className,
height,
}: FlutterViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading"
);
const stateRef = useRef(state);
stateRef.current = state;
const onEventRef = useRef(onEvent); // Module-level refs so bridge closures always read latest values
onEventRef.current = onEvent; let currentStateRef: FlutterTimelineState | null = null;
let currentOnEventRef: ((event: FlutterEvent) => void) | null = null;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let pollingInterval: ReturnType<typeof setInterval> | undefined;
let pollingTimeout: ReturnType<typeof setTimeout> | undefined;
function ensureBridge() {
if (window.__zendegi__) return;
window.__zendegi__ = { window.__zendegi__ = {
getState: () => JSON.stringify(stateRef.current), getState: () => JSON.stringify(currentStateRef),
onEvent: (json: string) => { onEvent: (json: string) => {
const event = JSON.parse(json) as FlutterEvent; const event = JSON.parse(json) as FlutterEvent;
onEventRef.current(event); currentOnEventRef?.(event);
}, },
}; };
}
const init = async () => { export function preloadFlutter() {
initFlutter();
}
function initFlutter(): Promise<void> {
if (initPromise) return initPromise;
initPromise = (async () => {
ensureBridge();
// Load flutter.js if not already loaded // Load flutter.js if not already loaded
if (!window._flutter) { if (!window._flutter) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
if (document.querySelector('script[src="/flutter/flutter.js"]')) { if (document.querySelector('script[src="/flutter/flutter.js"]')) {
// Script tag exists but hasn't finished — wait for _flutter to appear const timeout = setTimeout(() => {
pollingTimeout = setTimeout(() => { clearInterval(interval);
clearInterval(pollingInterval);
reject( reject(
new Error("Timed out waiting for flutter.js to initialize") new Error("Timed out waiting for flutter.js to initialize"),
); );
}, 10_000); }, 10_000);
pollingInterval = setInterval(() => { const interval = setInterval(() => {
if (window._flutter) { if (window._flutter) {
clearInterval(pollingInterval); clearInterval(interval);
clearTimeout(pollingTimeout); clearTimeout(timeout);
resolve(); resolve();
} }
}, 50); }, 50);
@@ -105,7 +97,13 @@ export function FlutterView({
window._flutter!.buildConfig = await res.json(); window._flutter!.buildConfig = await res.json();
} }
try { // Create the persistent container once
if (!flutterContainer) {
flutterContainer = document.createElement("div");
flutterContainer.style.width = "100%";
flutterContainer.style.height = "100%";
}
await window._flutter!.loader.load({ await window._flutter!.loader.load({
config: { config: {
entrypointBaseUrl: "/flutter/", entrypointBaseUrl: "/flutter/",
@@ -113,27 +111,81 @@ export function FlutterView({
}, },
onEntrypointLoaded: async (engineInitializer) => { onEntrypointLoaded: async (engineInitializer) => {
const appRunner = await engineInitializer.initializeEngine({ const appRunner = await engineInitializer.initializeEngine({
hostElement: container, hostElement: flutterContainer!,
assetBase: "/flutter/", assetBase: "/flutter/",
}); });
appRunner.runApp(); appRunner.runApp();
setStatus("ready");
}, },
}); });
} catch (error) {
throw new Error(
`Flutter engine initialization failed: ${error instanceof Error ? error.message : error}`
);
}
};
init().catch(() => setStatus("error")); engineReady = true;
})().catch((error) => {
// Reset all module state so next mount retries
flutterContainer = null;
engineReady = false;
initPromise = null;
throw error;
});
return initPromise;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
type FlutterViewProps = {
state: FlutterTimelineState;
onEvent: (event: FlutterEvent) => void;
className?: string;
height?: number;
};
export function FlutterView({
state,
onEvent,
className,
height,
}: FlutterViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
engineReady ? "ready" : "loading",
);
// Keep module-level refs in sync
currentStateRef = state;
currentOnEventRef = onEvent;
useEffect(() => {
const wrapper = containerRef.current;
if (!wrapper) return;
if (engineReady && flutterContainer) {
// Engine already running — reparent immediately, no loading flash
wrapper.appendChild(flutterContainer);
setStatus("ready");
// Push latest state so Flutter renders the correct timeline
window.__zendegi__?.updateState?.(JSON.stringify(currentStateRef));
} else {
// First mount — initialize engine
setStatus("loading");
initFlutter()
.then(() => {
// Guard: component may have unmounted while awaiting
if (!containerRef.current || !flutterContainer) return;
wrapper.appendChild(flutterContainer);
setStatus("ready");
// Push current state after engine is ready
window.__zendegi__?.updateState?.(JSON.stringify(currentStateRef));
})
.catch(() => setStatus("error"));
}
return () => { return () => {
clearInterval(pollingInterval); // Detach but don't destroy the Flutter container
clearTimeout(pollingTimeout); if (flutterContainer?.parentElement === wrapper) {
container.replaceChildren(); wrapper.removeChild(flutterContainer);
delete (window as Partial<Window>).__zendegi__; }
}; };
}, []); }, []);

View File

@@ -1,6 +1,8 @@
import { useEffect } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import { timelinesQueryOptions } from "@/functions/get-timelines"; import { timelinesQueryOptions } from "@/functions/get-timelines";
import { preloadFlutter } from "@/components/flutter-view";
export const Route = createFileRoute("/_default/timelines")({ export const Route = createFileRoute("/_default/timelines")({
loader: async ({ context }) => { loader: async ({ context }) => {
@@ -11,6 +13,10 @@ export const Route = createFileRoute("/_default/timelines")({
function RouteComponent() { function RouteComponent() {
const timelinesQuery = useSuspenseQuery(timelinesQueryOptions()); const timelinesQuery = useSuspenseQuery(timelinesQueryOptions());
useEffect(() => {
preloadFlutter();
}, []);
return ( return (
<section className="container mx-auto max-w-3xl px-4 py-2"> <section className="container mx-auto max-w-3xl px-4 py-2">
<h1 className="text-4xl font-serif font-bold mb-6">Timelines</h1> <h1 className="text-4xl font-serif font-bold mb-6">Timelines</h1>