mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
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:
co-authored by
Claude Opus 5.5
parent
96b6d6c6e5
commit
477b2aed12
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user