perf: keep monaco and deferred shell chunks off every page's load

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-26 23:05:04 +02:00
co-authored by Claude Opus 5.5
parent 96b6d6c6e5
commit 477b2aed12
7 changed files with 136 additions and 28 deletions
@@ -11,6 +11,7 @@
import { onDestroy } from 'svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { Menu } from 'lucide-svelte'
import { whenPageIdle } from '$lib/utils/paint'
interface Props {
noPadding?: boolean
@@ -102,7 +103,7 @@
{#if !disableAi}
<!-- Dynamic imports here and for AIChat: this layout wraps every workspace page, so its
static imports gate first paint. -->
{#await import('./CreatedResourceActionDrawers.svelte') then CreatedResourceActionDrawers}
{#await whenPageIdle().then(() => import('./CreatedResourceActionDrawers.svelte')) then CreatedResourceActionDrawers}
<CreatedResourceActionDrawers.default />
{/await}
<Splitpanes horizontal={false} class="flex-1 min-h-0">
@@ -1009,6 +1009,12 @@
// would put two of them on the page at once while the workspace is still empty.
let hubPick = $state<HubProjectPick | undefined>(undefined)
let hubPickerOpen = $state(false)
// Mounted on the first pick and kept, so its dismissal and in-flight run outlive `hubPick`.
let importModalMounted = $state(false)
function pickHubProject(project: HubProjectPick) {
importModalMounted = true
hubPick = project
}
/**
* Whether a workspace the default listing found empty is empty at all, or just has nothing
@@ -1941,7 +1947,7 @@
<WorkspaceEmptyState
archivedOnly={archivedProbe?.hasArchived === true}
canCreate={canCreateHere}
onPick={(project) => (hubPick = project)}
onPick={pickHubProject}
onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })}
/>
{:else}
@@ -2073,13 +2079,16 @@
onClose={() => (hubPickerOpen = false)}
onPick={(project) => {
hubPickerOpen = false
hubPick = project
pickHubProject(project)
}}
/>
<!-- Dynamic: the import flow statically reaches ~400 modules (resource setup, SQL editor). -->
{#await import('./ImportProjectModal.svelte') then ImportProjectModal}
<ImportProjectModal.default pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
{/await}
<!-- Dynamic and on demand: the import flow statically reaches ~400 modules, Monaco among
them (SQL editor). -->
{#if importModalMounted}
{#await import('./ImportProjectModal.svelte') then ImportProjectModal}
<ImportProjectModal.default pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
{/await}
{/if}
<style>
/* Rows arriving after an import, one after another. The animation is declared on the
@@ -9,7 +9,6 @@
import QuickMenuItem from './QuickMenuItem.svelte'
import { goto } from '$app/navigation'
import { displayDateOnly } from '$lib/utils'
import JobPreview from '../runs/JobRunsPreview.svelte'
let debounceTimeout: any = undefined
const debouncePeriod: number = 1000
@@ -210,7 +209,11 @@
Select a result to preview
{:else}
<div class="h-[95%] overflow-y-scroll">
<JobPreview id={selectedItem?.document?.id[0]} workspace={selectedWorkspace} />
<!-- Dynamic: the global search modal is warmed on every page, and the preview
reaches Monaco through the flow graph. -->
{#await import('../runs/JobRunsPreview.svelte') then JobPreview}
<JobPreview.default id={selectedItem?.document?.id[0]} workspace={selectedWorkspace} />
{/await}
</div>
{/if}
<div class="flex flex-row pt-3 pl-4 items-center text-xs text-secondary">
@@ -246,8 +249,9 @@
<div class="text-sm">
Note that new runs might take a while to become searchable (by default ~5min).
{#if indexMetadata?.max_index_time_window_secs}
Search only covers the last {Math.round(indexMetadata.max_index_time_window_secs / 86400)} day(s),
configurable in instance settings under Indexer.
Search only covers the last {Math.round(
indexMetadata.max_index_time_window_secs / 86400
)} day(s), configurable in instance settings under Indexer.
{/if}
</div>
{#if !$enterpriseLicense}
+47
View File
@@ -30,3 +30,50 @@ export async function tickPainted(): Promise<void> {
await nextAnimationFrame()
await nextAnimationFrame()
}
const QUIET_MS = 300
const MAX_WAIT_MS = 5000
let pageIdle: Promise<void> | undefined
/**
* Resolve once the first page has loaded and its network has gone quiet (no resource
* finished for `QUIET_MS`, capped at `MAX_WAIT_MS` after `load`), then the main thread
* is idle. Gate prefetches of chunks the page does not need yet on it: started any
* earlier they compete with the page's own chunks and API calls for connections and
* bandwidth, which delays its first content. Resolves once per tab.
*/
export function whenPageIdle(): Promise<void> {
return (pageIdle ??= new Promise((resolve) => {
if (typeof window === 'undefined') return resolve()
const settle = () => {
const loadedAt = performance.now()
let timer: ReturnType<typeof setTimeout> | undefined
let observer: PerformanceObserver | undefined
let finished = false
const done = () => {
if (finished) return
finished = true
observer?.disconnect()
clearTimeout(timer)
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => resolve(), { timeout: 1000 })
} else {
resolve()
}
}
const arm = () => {
if (finished) return
clearTimeout(timer)
const left = MAX_WAIT_MS - (performance.now() - loadedAt)
timer = setTimeout(done, Math.max(0, Math.min(QUIET_MS, left)))
}
try {
observer = new PerformanceObserver(arm)
observer.observe({ type: 'resource' })
} catch {}
arm()
}
if (document.readyState === 'complete') settle()
else window.addEventListener('load', settle, { once: true })
}))
}
@@ -65,6 +65,7 @@
getFavoriteLabel
} from '$lib/components/sidebar/FavoriteMenu.svelte'
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { whenPageIdle } from '$lib/utils/paint'
import { isCloudHosted } from '$lib/cloud'
import { PanelLeftClose, PanelLeftOpen, Home, Play, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
@@ -254,6 +255,20 @@
})
let userSettings: UserSettings | undefined = $state()
let superadminSettings: SuperadminSettings | undefined = $state()
// The shell's drawers and modals mount on first request, or once the page is idle so
// they open instantly. Not before: their chunks would compete with the page's own.
// Superadmin settings only mounts on request, as it reaches Monaco.
let pageIdle = $state(false)
whenPageIdle().then(() => (pageIdle = true))
let userSettingsRequested = $state(false)
let superadminSettingsRequested = $state(false)
let searchModalRequested = $state(false)
$effect(() => {
const hash = page.url.hash
if (hash.startsWith(USER_SETTINGS_HASH)) userSettingsRequested = true
if (hash === SUPERADMIN_SETTINGS_HASH) superadminSettingsRequested = true
})
let menuHidden = $state(false)
let isDarkMode = useIsDarkMode()
let darkMode = $derived(isDarkMode.val)
@@ -695,7 +710,10 @@
function openSearchModal(text?: string, stack?: OverlayStack): void {
if (globalSearchModal) globalSearchModal.openSearchWithPrefilledText(text, stack)
else if (searchModalMounts) pendingSearchOpen = { text, stack }
else if (searchModalMounts) {
pendingSearchOpen = { text, stack }
searchModalRequested = true
}
}
$effect(() => {
@@ -932,6 +950,11 @@
})
globalDbManagerDrawer.val = useDbManagerUriState()
// Mounted on first open and kept for its close transition: it reaches Monaco.
let dbManagerRequested = $state(false)
$effect(() => {
if (globalDbManagerDrawer.val?.open) dbManagerRequested = true
})
let globalS3FilePicker: S3FilePicker | undefined = $state()
$effect(() => {
@@ -1018,9 +1041,11 @@
<!-- The drawers and modals below are dynamic imports: this layout wraps every workspace
page, so whatever it imports statically gates first paint. -->
{#await import('$lib/components/UserSettings.svelte') then UserSettings}
<UserSettings.default bind:this={userSettings} showMcpMode={true} />
{/await}
{#if pageIdle || userSettingsRequested}
{#await import('$lib/components/UserSettings.svelte') then UserSettings}
<UserSettings.default bind:this={userSettings} showMcpMode={true} />
{/await}
{/if}
{#if accountSetup.pending}
<FinishAccountSetup
bind:open={accountSetup.open}
@@ -1032,10 +1057,12 @@
{#if page.status == 404}
<CenteredModal title="Page not found, redirecting you to login" loading={true}></CenteredModal>
{:else if $userStore}
{#await import('$lib/components/search/GlobalSearchModal.svelte') then GlobalSearchModal}
<GlobalSearchModal.default bind:this={globalSearchModal} />
{/await}
{#if $superadmin}
{#if pageIdle || searchModalRequested}
{#await import('$lib/components/search/GlobalSearchModal.svelte') then GlobalSearchModal}
<GlobalSearchModal.default bind:this={globalSearchModal} />
{/await}
{/if}
{#if $superadmin && superadminSettingsRequested}
{#await import('$lib/components/SuperadminSettings.svelte') then SuperadminSettings}
<SuperadminSettings.default bind:this={superadminSettings} />
{/await}
@@ -1536,7 +1563,7 @@
<CenteredModal title="Loading user..." loading={true}></CenteredModal>
{/if}
{#if $workspaceStore && globalDbManagerDrawer.val}
{#if $workspaceStore && globalDbManagerDrawer.val && dbManagerRequested}
{#await import('$lib/components/DBManagerDrawer.svelte') then DBManagerDrawer}
<DBManagerDrawer.default uriState={globalDbManagerDrawer.val} />
{/await}
+13 -1
View File
@@ -205,10 +205,22 @@
location.reload()
return
}
// A chunk gone from the server, typically a tab left open across an upgrade:
// a component loaded on demand would otherwise silently never appear.
if (message.startsWith('Failed to fetch dynamically imported')) {
console.warn(message)
sendUserToast(
'Part of the page failed to load, Windmill may have been updated',
true,
[{ label: 'Reload', callback: () => location.reload() }],
undefined,
15000
)
return
}
// Unhandled errors from Monaco Editor don't logout the user
if (
monacoEditorUnhandledErrors.includes(message) ||
message.startsWith('Failed to fetch dynamically imported') ||
message.startsWith('Unable to figure out browser width and height') ||
message.startsWith('Unable to read file') ||
message.startsWith('Could not find source file')
+15 -7
View File
@@ -83,21 +83,29 @@ function assertAcyclicChunks() {
* These pages also serve raw apps, which only need a small shell around their bundle's
* iframe. One static import of AppPreview (or of anything reaching monaco) makes every
* public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts.
*
* The workspace shell and home page must not reach monaco either: it is ~2 MB gzipped
* that every page would download whether or not it shows an editor.
*/
function assertLeanPublicAppRoutes() {
const monaco = '/node_modules/monaco-editor/'
// Route directories, so +page.js counts as well as +page.svelte.
const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/']
const forbidden = [
'/src/lib/components/apps/editor/AppPreview.svelte',
'/node_modules/monaco-editor/'
]
const forbiddenByRoute = {
'/src/routes/public/[workspace]/[...secret]/': [
'/src/lib/components/apps/editor/AppPreview.svelte',
monaco
],
'/src/routes/a/[...path]/': ['/src/lib/components/apps/editor/AppPreview.svelte', monaco],
'/src/routes/(root)/(logged)/+layout.svelte': [monaco],
'/src/routes/(root)/(logged)/+page.svelte': [monaco]
}
return {
name: 'wm-assert-lean-public-app-routes',
generateBundle(_options, bundle) {
const chunks = Object.entries(bundle).filter(([, c]) => c.type === 'chunk')
if (!chunks.some(([file]) => file.startsWith('_app/immutable/'))) return
const idsOf = (c) => c.moduleIds ?? Object.keys(c.modules ?? {})
for (const route of routes) {
for (const [route, forbidden] of Object.entries(forbiddenByRoute)) {
const starts = chunks
.filter(([, c]) => idsOf(c).some((id) => id.includes(route)))
.map(([file]) => file)
@@ -110,7 +118,7 @@ function assertLeanPublicAppRoutes() {
if (!chunk) continue
const hit = idsOf(chunk).find((id) => forbidden.some((f) => id.includes(f)))
if (hit) {
this.error(`${route} statically loads ${hit}; import it lazily (see loadAppPreview.ts)`)
this.error(`${route} statically loads ${hit}; import it lazily`)
}
for (const dep of chunk.imports ?? []) {
if (seen.has(dep)) continue