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