Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perf-cold-start-bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Speed up cold start by keeping the PDF viewer out of the initial bundle (it now loads on demand), cutting the amount of JavaScript fetched and parsed on launch. The desktop and mobile windows also paint the app's background color immediately, so startup no longer flashes a blank black screen before the first frame.
17 changes: 17 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@
<script>
window.global ||= window;
</script>
<script>
(function () {
var base = '%BASE_URL%';
var lng = (localStorage.getItem('i18nextLng') || navigator.language || 'en').split('-')[0];
var configPromise = fetch(base + 'config.json').then(function (r) {
if (!r.ok) return undefined;
return r.json();
});
var localePromise = fetch(base + 'public/locales/' + lng + '.json');
window.__SABLE_PRELOAD = {
config: configPromise,
locale: { lng: lng, promise: localePromise },
};
configPromise.catch(function () {});
localePromise.catch(function () {});
})();
</script>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
Expand Down
22 changes: 21 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ pub fn show_or_create_main_window(app: &AppHandle<crate::BrowserEngine>) -> taur
let builder =
tauri::WebviewWindowBuilder::new(app, MAIN_WINDOW_LABEL, tauri::WebviewUrl::default())
.disable_drag_drop_handler()
.use_https_scheme(true);
.use_https_scheme(true)
.background_color(tauri::window::Color(0x1A, 0x1C, 0x28, 0xFF));

#[cfg(desktop)]
let title = if app
Expand Down Expand Up @@ -276,6 +277,25 @@ pub fn run() {

show_or_create_main_window(app.handle())?;

// Failsafe: if the frontend never calls show() (hung webview), force-show
// the window after 5s so the app isn't invisible forever.
#[cfg(desktop)]
{
let handle = app.handle().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(5));
if let Some(window) = handle.get_webview_window(MAIN_WINDOW_LABEL) {
if !window.is_visible().unwrap_or(true) {
let _ = window.show();
let _ = window.set_focus();
log::warn!(
"Frontend show() not received within 5s — force-showing window"
);
}
}
});
}

#[cfg(target_os = "ios")]
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
ios::hide_form_accessory_bar(&window);
Expand Down
10 changes: 10 additions & 0 deletions src/app/components/ClientConfigLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Dialog, Text, color, config } from 'folds';
import type { ClientConfig } from '$hooks/useClientConfig';
import { takePreloadedConfig } from '$utils/preload';
import { trimTrailingSlash } from '$utils/common';
import { fetch } from '$utils/fetch';
import { SplashScreen } from '$components/splash-screen';
Expand All @@ -14,6 +15,15 @@ export const FALLBACK_CLIENT_CONFIG: ClientConfig = {
};

export const getClientConfig = async (): Promise<ClientConfig> => {
const preloaded = takePreloadedConfig();
if (preloaded !== undefined) {
const data = await preloaded.catch(() => undefined);
if (data && typeof data === 'object' && !Array.isArray(data)) {
return data as ClientConfig;
}
// Preload missed or invalid — fall through to fresh fetch.
}

const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
Expand Down
7 changes: 6 additions & 1 deletion src/app/components/page/MobileNavDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,14 +269,18 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
return;
}

if (mx > DIRECTION_DEADZONE || !canOpenRoom) {
if (mx > DIRECTION_DEADZONE) {
if (draggingRef.current) {
draggingRef.current = false;
settle(0);
}
cancel();
return;
}
if (!canOpenRoom && mx < -DIRECTION_DEADZONE) {
cancel();
return;
}
if (active) {
if (first) {
x.stop();
Expand Down Expand Up @@ -307,6 +311,7 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
{
axis: 'x',
filterTaps: true,
tapsThreshold: DIRECTION_DEADZONE,
pointer: { capture: false },
from: () => [readX(), 0],
}
Expand Down
11 changes: 10 additions & 1 deletion src/app/components/tauri/TauriFrontendReady.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,20 @@ function setDocumentReadyState(value: DocumentReadyState) {
});
}

function setDocumentVisibility(value: DocumentVisibilityState) {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value,
});
}

describe('TauriFrontendReady', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsTauri.mockReturnValue(true);
mockGetCurrentWindow.mockReturnValue({ show: mockShow });
setDocumentReadyState('complete');
setDocumentVisibility('visible');
});

afterEach(() => {
Expand Down Expand Up @@ -77,9 +85,10 @@ describe('TauriFrontendReady', () => {
expect(mockGetCurrentWindow).toHaveBeenCalledOnce();
});

it('waits for the window load event before showing the desktop window', async () => {
it('falls back to the load event when the document is hidden and still loading', async () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
mockOsType.mockReturnValue('linux');
setDocumentVisibility('hidden');
setDocumentReadyState('loading');

render(<TauriFrontendReady />);
Expand Down
31 changes: 17 additions & 14 deletions src/app/components/tauri/TauriFrontendReady.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ import { createLogger } from '$utils/debug';

const log = createLogger('TauriFrontendReady');

function onPageFullyLoaded(cb: () => void): () => void {
if (document.readyState === 'complete') {
cb();
return () => {};
// Show the window at the first paint frame after the shell mounts, not after
// the full `window.load` event (which fires after ALL resources). This cuts the
// perceived blank-screen time substantially on desktop cold starts.
function showOnFirstPaint(cb: () => void): () => void {
if (document.visibilityState === 'hidden') {
// Headless/test: fall back to load event to avoid showing a hidden window.
if (document.readyState === 'complete') {
cb();
return () => {};
}
const handleLoad = () => cb();
window.addEventListener('load', handleLoad, { once: true });
return () => window.removeEventListener('load', handleLoad);
}

const handleLoad = () => {
cb();
};

window.addEventListener('load', handleLoad, { once: true });
return () => {
window.removeEventListener('load', handleLoad);
};
const id = requestAnimationFrame(() => cb());
return () => cancelAnimationFrame(id);
}

export function TauriFrontendReady() {
Expand All @@ -30,9 +33,9 @@ export function TauriFrontendReady() {
if (os !== 'windows' && os !== 'linux' && os !== 'macos') return undefined;

const appWindow = getCurrentWindow();
return onPageFullyLoaded(() => {
return showOnFirstPaint(() => {
appWindow.show().catch((error) => {
log.warn('Failed to show main window after frontend fully loaded:', error);
log.warn('Failed to show main window after first paint:', error);
});
});
}, []);
Expand Down
11 changes: 11 additions & 0 deletions src/app/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import type { HttpBackendOptions } from 'i18next-http-backend';
import Backend from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';
import { trimTrailingSlash } from './utils/common';
import { takePreloadedLocale } from './utils/preload';

const langFromUrl = (url: string): string | undefined => {
const match = url.match(/\/public\/locales\/([^/]+)\.json/);
return match?.[1];
};

i18n
// i18next-http-backend
Expand All @@ -26,6 +32,11 @@ i18n
load: 'languageOnly',
backend: {
loadPath: `${trimTrailingSlash(import.meta.env.BASE_URL)}/public/locales/{{lng}}.json`,
alternateFetch: (url: string) => {
const lng = langFromUrl(url);
if (!lng) return undefined;
return takePreloadedLocale(lng) ?? undefined;
},
},
});

Expand Down
Loading
Loading