@ogygia/core 0.0.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -1
- package/ambient.d.ts +194 -0
- package/dist/ClientBindingStub.svelte +6 -0
- package/dist/ClientBindingStub.svelte.d.ts +26 -0
- package/dist/KeepHost.svelte +19 -0
- package/dist/KeepHost.svelte.d.ts +10 -0
- package/dist/LakeBoundary.svelte +16 -0
- package/dist/LakeBoundary.svelte.d.ts +11 -0
- package/dist/LateIsland.svelte +22 -0
- package/dist/LateIsland.svelte.d.ts +15 -0
- package/dist/LateIslandAwait.svelte +17 -0
- package/dist/LateIslandAwait.svelte.d.ts +15 -0
- package/dist/LiveHost.svelte +19 -0
- package/dist/LiveHost.svelte.d.ts +10 -0
- package/dist/NestedProvider.svelte +13 -0
- package/dist/NestedProvider.svelte.d.ts +8 -0
- package/dist/OgygiaBoundary.svelte +5 -0
- package/dist/OgygiaBoundary.svelte.d.ts +17 -0
- package/dist/PromiseRegion.svelte +40 -0
- package/dist/PromiseRegion.svelte.d.ts +15 -0
- package/dist/PromiseRegionAwait.svelte +27 -0
- package/dist/PromiseRegionAwait.svelte.d.ts +15 -0
- package/dist/Provide.svelte +27 -0
- package/dist/Provide.svelte.d.ts +8 -0
- package/dist/RawHtml.svelte +9 -0
- package/dist/RawHtml.svelte.d.ts +6 -0
- package/dist/Region.svelte +1173 -0
- package/dist/Region.svelte.d.ts +68 -0
- package/dist/SlotBoundary.svelte +13 -0
- package/dist/SlotBoundary.svelte.d.ts +7 -0
- package/dist/app.d.ts +4 -0
- package/dist/app.js +3 -0
- package/dist/boundary.d.ts +35 -0
- package/dist/boundary.js +127 -0
- package/dist/build-cache.d.ts +13 -0
- package/dist/build-cache.js +77 -0
- package/dist/compiler/blank-styles.d.ts +1 -0
- package/dist/compiler/blank-styles.js +63 -0
- package/dist/compiler/bom.d.ts +2 -0
- package/dist/compiler/bom.js +15 -0
- package/dist/compiler/browser-full.d.ts +8 -0
- package/dist/compiler/browser-full.js +8 -0
- package/dist/compiler/browser.d.ts +11 -0
- package/dist/compiler/browser.js +11 -0
- package/dist/compiler/content/transportables.d.ts +26 -0
- package/dist/compiler/content/transportables.js +219 -0
- package/dist/compiler/ctx.d.ts +180 -0
- package/dist/compiler/ctx.js +183 -0
- package/dist/compiler/debarrel/barrel.d.ts +55 -0
- package/dist/compiler/debarrel/barrel.js +248 -0
- package/dist/compiler/debarrel/options.d.ts +67 -0
- package/dist/compiler/debarrel/options.js +31 -0
- package/dist/compiler/debarrel/parse.d.ts +65 -0
- package/dist/compiler/debarrel/parse.js +256 -0
- package/dist/compiler/debarrel/plugin.d.ts +31 -0
- package/dist/compiler/debarrel/plugin.js +190 -0
- package/dist/compiler/debarrel/rewrite.d.ts +27 -0
- package/dist/compiler/debarrel/rewrite.js +118 -0
- package/dist/compiler/debarrel/svelte.d.ts +13 -0
- package/dist/compiler/debarrel/svelte.js +56 -0
- package/dist/compiler/dev/css-scope.d.ts +16 -0
- package/dist/compiler/dev/css-scope.js +38 -0
- package/dist/compiler/dev/dev-hmr.d.ts +25 -0
- package/dist/compiler/dev/dev-hmr.js +29 -0
- package/dist/compiler/dev/hmr.d.ts +34 -0
- package/dist/compiler/dev/hmr.js +57 -0
- package/dist/compiler/dev/region-bytes.d.ts +34 -0
- package/dist/compiler/dev/region-bytes.js +126 -0
- package/dist/compiler/driver.d.ts +280 -0
- package/dist/compiler/driver.js +1352 -0
- package/dist/compiler/flags.d.ts +16 -0
- package/dist/compiler/flags.js +101 -0
- package/dist/compiler/fouc-css.d.ts +97 -0
- package/dist/compiler/fouc-css.js +335 -0
- package/dist/compiler/free-vars.d.ts +20 -0
- package/dist/compiler/free-vars.js +327 -0
- package/dist/compiler/host.d.ts +65 -0
- package/dist/compiler/host.js +51 -0
- package/dist/compiler/ids.d.ts +84 -0
- package/dist/compiler/ids.js +86 -0
- package/dist/compiler/index.d.ts +11 -0
- package/dist/compiler/index.js +11 -0
- package/dist/compiler/kit.d.ts +129 -0
- package/dist/compiler/kit.js +563 -0
- package/dist/compiler/link/build-output.d.ts +44 -0
- package/dist/compiler/link/build-output.js +147 -0
- package/dist/compiler/link/caps.d.ts +48 -0
- package/dist/compiler/link/caps.js +64 -0
- package/dist/compiler/link/context-detect.d.ts +23 -0
- package/dist/compiler/link/context-detect.js +137 -0
- package/dist/compiler/link/emit-gate.d.ts +11 -0
- package/dist/compiler/link/emit-gate.js +40 -0
- package/dist/compiler/link/entry-shim.d.ts +4 -0
- package/dist/compiler/link/entry-shim.js +29 -0
- package/dist/compiler/link/island-deps.d.ts +142 -0
- package/dist/compiler/link/island-deps.js +525 -0
- package/dist/compiler/link/manifest.d.ts +5 -0
- package/dist/compiler/link/manifest.js +9 -0
- package/dist/compiler/link/page-keys.d.ts +64 -0
- package/dist/compiler/link/page-keys.js +683 -0
- package/dist/compiler/link/registry-stub.d.ts +41 -0
- package/dist/compiler/link/registry-stub.js +212 -0
- package/dist/compiler/link/router-config.d.ts +11 -0
- package/dist/compiler/link/router-config.js +15 -0
- package/dist/compiler/link/router-css.d.ts +22 -0
- package/dist/compiler/link/router-css.js +142 -0
- package/dist/compiler/link/runtime-entry.d.ts +71 -0
- package/dist/compiler/link/runtime-entry.js +132 -0
- package/dist/compiler/link/server-manifest.d.ts +12 -0
- package/dist/compiler/link/server-manifest.js +45 -0
- package/dist/compiler/link/speculation.d.ts +17 -0
- package/dist/compiler/link/speculation.js +36 -0
- package/dist/compiler/link/transport.d.ts +31 -0
- package/dist/compiler/link/transport.js +67 -0
- package/dist/compiler/macros/bake.d.ts +20 -0
- package/dist/compiler/macros/bake.js +317 -0
- package/dist/compiler/macros/browser.d.ts +11 -0
- package/dist/compiler/macros/browser.js +69 -0
- package/dist/compiler/macros/dedent.d.ts +12 -0
- package/dist/compiler/macros/dedent.js +36 -0
- package/dist/compiler/macros/dollar.d.ts +15 -0
- package/dist/compiler/macros/dollar.js +387 -0
- package/dist/compiler/macros/pipeline.d.ts +31 -0
- package/dist/compiler/macros/pipeline.js +106 -0
- package/dist/compiler/macros/source.d.ts +6 -0
- package/dist/compiler/macros/source.js +127 -0
- package/dist/compiler/macros/store.d.ts +17 -0
- package/dist/compiler/macros/store.js +244 -0
- package/dist/compiler/macros/wire.d.ts +14 -0
- package/dist/compiler/macros/wire.js +160 -0
- package/dist/compiler/ownership-stamps.d.ts +6 -0
- package/dist/compiler/ownership-stamps.js +144 -0
- package/dist/compiler/parse/oxc.d.ts +34 -0
- package/dist/compiler/parse/oxc.js +59 -0
- package/dist/compiler/parse/scan.d.ts +43 -0
- package/dist/compiler/parse/scan.js +232 -0
- package/dist/compiler/program.d.ts +140 -0
- package/dist/compiler/program.js +242 -0
- package/dist/compiler/region/emit.d.ts +169 -0
- package/dist/compiler/region/emit.js +286 -0
- package/dist/compiler/region/identity.d.ts +28 -0
- package/dist/compiler/region/identity.js +75 -0
- package/dist/compiler/region/ir.d.ts +65 -0
- package/dist/compiler/region/ir.js +0 -0
- package/dist/compiler/region/transform.d.ts +178 -0
- package/dist/compiler/region/transform.js +1933 -0
- package/dist/compiler/script-request.d.ts +1 -0
- package/dist/compiler/script-request.js +43 -0
- package/dist/context-bridge.d.ts +65 -0
- package/dist/context-bridge.js +217 -0
- package/dist/context-registry.d.ts +40 -0
- package/dist/context-registry.js +15 -0
- package/dist/context.d.ts +72 -0
- package/dist/context.js +158 -0
- package/dist/current-region.d.ts +14 -0
- package/dist/current-region.js +36 -0
- package/dist/document.d.ts +41 -0
- package/dist/document.js +125 -0
- package/dist/entry-hydrate.d.ts +5 -0
- package/dist/entry-hydrate.js +42 -0
- package/dist/escape.d.ts +18 -0
- package/dist/escape.js +37 -0
- package/dist/events/bus.d.ts +33 -0
- package/dist/events/bus.js +153 -0
- package/dist/events/index.d.ts +4 -0
- package/dist/events/index.js +4 -0
- package/dist/events/schema.d.ts +389 -0
- package/dist/events/schema.js +23 -0
- package/dist/events/server-registry.d.ts +7 -0
- package/dist/events/server-registry.js +13 -0
- package/dist/events/sinks.d.ts +64 -0
- package/dist/events/sinks.js +85 -0
- package/dist/fn-transport.d.ts +33 -0
- package/dist/fn-transport.js +140 -0
- package/dist/frame.d.ts +20 -0
- package/dist/frame.js +19 -0
- package/dist/holes-record.d.ts +6 -0
- package/dist/holes-record.js +8 -0
- package/dist/hooks.d.ts +43 -0
- package/dist/hooks.js +1191 -0
- package/dist/hydration-info.d.ts +1 -0
- package/dist/hydration-info.js +31 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +17 -0
- package/dist/insights/attr-rules.d.ts +15 -0
- package/dist/insights/attr-rules.js +27 -0
- package/dist/insights/cpu.d.ts +82 -0
- package/dist/insights/cpu.js +177 -0
- package/dist/insights/fp-drift.d.ts +28 -0
- package/dist/insights/fp-drift.js +148 -0
- package/dist/insights/hole-segments.d.ts +26 -0
- package/dist/insights/hole-segments.js +50 -0
- package/dist/insights/html-diff.d.ts +25 -0
- package/dist/insights/html-diff.js +157 -0
- package/dist/insights/hydration-hazards.d.ts +36 -0
- package/dist/insights/hydration-hazards.js +136 -0
- package/dist/insights/hydration-status.d.ts +2 -0
- package/dist/insights/hydration-status.js +0 -0
- package/dist/insights/index.d.ts +14 -0
- package/dist/insights/index.js +12 -0
- package/dist/insights/page-assets.d.ts +211 -0
- package/dist/insights/page-assets.js +608 -0
- package/dist/insights/page-insights.d.ts +749 -0
- package/dist/insights/page-insights.js +2157 -0
- package/dist/insights/session-data.d.ts +108 -0
- package/dist/insights/session-data.js +0 -0
- package/dist/insights/session-insights.d.ts +121 -0
- package/dist/insights/session-insights.js +548 -0
- package/dist/insights/switch.d.ts +53 -0
- package/dist/insights/switch.js +91 -0
- package/dist/insights/testing-version.d.ts +19 -0
- package/dist/insights/testing-version.js +21 -0
- package/dist/insights/third-party.d.ts +75 -0
- package/dist/insights/third-party.js +160 -0
- package/dist/internal-instruments.d.ts +13 -0
- package/dist/internal-instruments.js +12 -0
- package/dist/internal-reconcile.d.ts +3 -0
- package/dist/internal-reconcile.js +3 -0
- package/dist/internal-register.d.ts +7 -0
- package/dist/internal-register.js +7 -0
- package/dist/internal-server.d.ts +7 -0
- package/dist/internal-server.js +7 -0
- package/dist/internal.d.ts +13 -0
- package/dist/internal.js +13 -0
- package/dist/island-graph.d.ts +36 -0
- package/dist/island-graph.js +89 -0
- package/dist/keep-fallback-marker.d.ts +7 -0
- package/dist/keep-fallback-marker.js +9 -0
- package/dist/keep-fallback.d.ts +13 -0
- package/dist/keep-fallback.js +39 -0
- package/dist/kit-paths.d.ts +2 -0
- package/dist/kit-paths.js +39 -0
- package/dist/late-region-registry.d.ts +40 -0
- package/dist/late-region-registry.js +36 -0
- package/dist/live-transport.d.ts +57 -0
- package/dist/live-transport.js +145 -0
- package/dist/markup.d.ts +4 -0
- package/dist/markup.js +4 -0
- package/dist/own-package.d.ts +15 -0
- package/dist/own-package.js +20 -0
- package/dist/page-defer.d.ts +33 -0
- package/dist/page-defer.js +35 -0
- package/dist/page-facts-script.d.ts +7 -0
- package/dist/page-facts-script.js +9 -0
- package/dist/page-seed-registry.d.ts +72 -0
- package/dist/page-seed-registry.js +31 -0
- package/dist/plugin/capability.d.ts +14 -0
- package/dist/plugin/capability.js +44 -0
- package/dist/plugin/client.d.ts +39 -0
- package/dist/plugin/client.js +6 -0
- package/dist/plugin/css.d.ts +3 -0
- package/dist/plugin/css.js +54 -0
- package/dist/plugin/facts.d.ts +42 -0
- package/dist/plugin/facts.js +37 -0
- package/dist/plugin/hooks.d.ts +33 -0
- package/dist/plugin/hooks.js +38 -0
- package/dist/plugin/index.d.ts +127 -0
- package/dist/plugin/index.js +26 -0
- package/dist/plugin/routes.d.ts +32 -0
- package/dist/plugin/routes.js +35 -0
- package/dist/plugin/server.d.ts +27 -0
- package/dist/plugin/server.js +92 -0
- package/dist/plugin/standard-schema.d.ts +27 -0
- package/dist/plugin/standard-schema.js +0 -0
- package/dist/plugin/testing.d.ts +13 -0
- package/dist/plugin/testing.js +54 -0
- package/dist/plugin/utils.d.ts +13 -0
- package/dist/plugin/utils.js +19 -0
- package/dist/portable-form.d.ts +22 -0
- package/dist/portable-form.js +52 -0
- package/dist/preference.d.ts +35 -0
- package/dist/preference.js +84 -0
- package/dist/preload.d.ts +1 -0
- package/dist/preload.js +21 -0
- package/dist/ref-watch.d.ts +31 -0
- package/dist/ref-watch.js +66 -0
- package/dist/ref.d.ts +124 -0
- package/dist/ref.js +272 -0
- package/dist/region-brand.d.ts +8 -0
- package/dist/region-brand.js +10 -0
- package/dist/region-core.d.ts +2 -0
- package/dist/region-core.js +1 -0
- package/dist/region-snippet.d.ts +73 -0
- package/dist/region-snippet.js +351 -0
- package/dist/region.d.ts +127 -0
- package/dist/region.js +162 -0
- package/dist/request-event.d.ts +1 -0
- package/dist/request-event.js +37 -0
- package/dist/router-css.d.ts +39 -0
- package/dist/router-css.js +66 -0
- package/dist/runtime/app-transport.d.ts +2 -0
- package/dist/runtime/app-transport.js +13 -0
- package/dist/runtime/beacon.d.ts +271 -0
- package/dist/runtime/beacon.js +2190 -0
- package/dist/runtime/boot-link.d.ts +1 -0
- package/dist/runtime/boot-link.js +44 -0
- package/dist/runtime/client-hooks.d.ts +5 -0
- package/dist/runtime/client-hooks.js +22 -0
- package/dist/runtime/client-plugins.d.ts +8 -0
- package/dist/runtime/client-plugins.js +34 -0
- package/dist/runtime/concurrency.d.ts +12 -0
- package/dist/runtime/concurrency.js +29 -0
- package/dist/runtime/connected.d.ts +12 -0
- package/dist/runtime/connected.js +16 -0
- package/dist/runtime/context.d.ts +1 -0
- package/dist/runtime/context.js +19 -0
- package/dist/runtime/core.d.ts +15 -0
- package/dist/runtime/core.js +1173 -0
- package/dist/runtime/drift-watch.d.ts +9 -0
- package/dist/runtime/drift-watch.js +60 -0
- package/dist/runtime/entry-locations.d.ts +33 -0
- package/dist/runtime/entry-locations.js +119 -0
- package/dist/runtime/foreign-props.d.ts +8 -0
- package/dist/runtime/foreign-props.js +14 -0
- package/dist/runtime/form-continuity.d.ts +10 -0
- package/dist/runtime/form-continuity.js +107 -0
- package/dist/runtime/frame-nav.d.ts +36 -0
- package/dist/runtime/frame-nav.js +225 -0
- package/dist/runtime/frame-store.d.ts +62 -0
- package/dist/runtime/frame-store.js +214 -0
- package/dist/runtime/frames.d.ts +1 -0
- package/dist/runtime/frames.js +25 -0
- package/dist/runtime/full.d.ts +4 -0
- package/dist/runtime/full.js +32 -0
- package/dist/runtime/hash.d.ts +41 -0
- package/dist/runtime/hash.js +79 -0
- package/dist/runtime/hole-facts.d.ts +8 -0
- package/dist/runtime/hole-facts.js +57 -0
- package/dist/runtime/host-classes.d.ts +24 -0
- package/dist/runtime/host-classes.js +43 -0
- package/dist/runtime/hydrate-core.d.ts +101 -0
- package/dist/runtime/hydrate-core.js +605 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/interaction-replay.d.ts +11 -0
- package/dist/runtime/interaction-replay.js +70 -0
- package/dist/runtime/interaction.d.ts +33 -0
- package/dist/runtime/interaction.js +217 -0
- package/dist/runtime/island-graph-preload.d.ts +13 -0
- package/dist/runtime/island-graph-preload.js +99 -0
- package/dist/runtime/kit-boot.d.ts +29 -0
- package/dist/runtime/kit-boot.js +60 -0
- package/dist/runtime/kit-page-thread.svelte.d.ts +1 -0
- package/dist/runtime/kit-page-thread.svelte.js +116 -0
- package/dist/runtime/lake-anchors.d.ts +13 -0
- package/dist/runtime/lake-anchors.js +19 -0
- package/dist/runtime/lakes.d.ts +15 -0
- package/dist/runtime/lakes.js +212 -0
- package/dist/runtime/live.d.ts +3 -0
- package/dist/runtime/live.js +17 -0
- package/dist/runtime/load-scheduler.d.ts +53 -0
- package/dist/runtime/load-scheduler.js +317 -0
- package/dist/runtime/morph.d.ts +42 -0
- package/dist/runtime/morph.js +671 -0
- package/dist/runtime/nav-handle.d.ts +25 -0
- package/dist/runtime/nav-handle.js +71 -0
- package/dist/runtime/nav-hooks.d.ts +2 -0
- package/dist/runtime/nav-hooks.js +23 -0
- package/dist/runtime/nearest-attr.d.ts +4 -0
- package/dist/runtime/nearest-attr.js +16 -0
- package/dist/runtime/observe.d.ts +27 -0
- package/dist/runtime/observe.js +63 -0
- package/dist/runtime/own-cost.d.ts +9 -0
- package/dist/runtime/own-cost.js +35 -0
- package/dist/runtime/ownership.d.ts +101 -0
- package/dist/runtime/ownership.js +115 -0
- package/dist/runtime/page-cache.d.ts +23 -0
- package/dist/runtime/page-cache.js +74 -0
- package/dist/runtime/page-defer.d.ts +14 -0
- package/dist/runtime/page-defer.js +103 -0
- package/dist/runtime/parse-html.d.ts +40 -0
- package/dist/runtime/parse-html.js +81 -0
- package/dist/runtime/reconcile.d.ts +41 -0
- package/dist/runtime/reconcile.js +194 -0
- package/dist/runtime/region-attrs.d.ts +95 -0
- package/dist/runtime/region-attrs.js +153 -0
- package/dist/runtime/region-endpoint-url.d.ts +76 -0
- package/dist/runtime/region-endpoint-url.js +169 -0
- package/dist/runtime/region-ids.d.ts +8 -0
- package/dist/runtime/region-ids.js +33 -0
- package/dist/runtime/remote-seeds.d.ts +2 -0
- package/dist/runtime/remote-seeds.js +21 -0
- package/dist/runtime/render-blocking.d.ts +2 -0
- package/dist/runtime/render-blocking.js +30 -0
- package/dist/runtime/restore.d.ts +34 -0
- package/dist/runtime/restore.js +390 -0
- package/dist/runtime/router-nav.d.ts +63 -0
- package/dist/runtime/router-nav.js +740 -0
- package/dist/runtime/router.d.ts +201 -0
- package/dist/runtime/router.js +593 -0
- package/dist/runtime/schedule.d.ts +49 -0
- package/dist/runtime/schedule.js +256 -0
- package/dist/runtime/seeds.d.ts +50 -0
- package/dist/runtime/seeds.js +208 -0
- package/dist/runtime/session.d.ts +52 -0
- package/dist/runtime/session.js +130 -0
- package/dist/runtime/sidecar.d.ts +3 -0
- package/dist/runtime/sidecar.js +56 -0
- package/dist/runtime/slots.d.ts +183 -0
- package/dist/runtime/slots.js +44 -0
- package/dist/runtime/spa-cacheable.d.ts +7 -0
- package/dist/runtime/spa-cacheable.js +18 -0
- package/dist/runtime/speculate-hint.d.ts +1 -0
- package/dist/runtime/speculate-hint.js +35 -0
- package/dist/runtime/svelte-warnings.d.ts +28 -0
- package/dist/runtime/svelte-warnings.js +71 -0
- package/dist/runtime/wire-classes.d.ts +3 -0
- package/dist/runtime/wire-classes.js +69 -0
- package/dist/runtime/wire-format.d.ts +7 -0
- package/dist/runtime/wire-format.js +22 -0
- package/dist/script.d.ts +134 -0
- package/dist/script.js +184 -0
- package/dist/seed-refs.d.ts +105 -0
- package/dist/seed-refs.js +487 -0
- package/dist/server/capability-lifetime.d.ts +28 -0
- package/dist/server/capability-lifetime.js +31 -0
- package/dist/server/dead-kit-resolves.d.ts +1 -0
- package/dist/server/dead-kit-resolves.js +39 -0
- package/dist/server/document-assembly.d.ts +30 -0
- package/dist/server/document-assembly.js +26 -0
- package/dist/server/document-tail.d.ts +113 -0
- package/dist/server/document-tail.js +208 -0
- package/dist/server/endpoint.d.ts +51 -0
- package/dist/server/endpoint.js +90 -0
- package/dist/server/entry-location.d.ts +8 -0
- package/dist/server/entry-location.js +29 -0
- package/dist/server/fingerprint.d.ts +1 -0
- package/dist/server/fingerprint.js +25 -0
- package/dist/server/head-presence.d.ts +45 -0
- package/dist/server/head-presence.js +156 -0
- package/dist/server/hmac.d.ts +38 -0
- package/dist/server/hmac.js +89 -0
- package/dist/server/hole-graph.d.ts +7 -0
- package/dist/server/hole-graph.js +71 -0
- package/dist/server/hole-urls.d.ts +3 -0
- package/dist/server/hole-urls.js +76 -0
- package/dist/server/html-scan.d.ts +19 -0
- package/dist/server/html-scan.js +60 -0
- package/dist/server/kit-context.d.ts +78 -0
- package/dist/server/kit-context.js +110 -0
- package/dist/server/late-wire.d.ts +22 -0
- package/dist/server/late-wire.js +28 -0
- package/dist/server/page-facts.d.ts +25 -0
- package/dist/server/page-facts.js +138 -0
- package/dist/server/page-seed.d.ts +33 -0
- package/dist/server/page-seed.js +60 -0
- package/dist/server/page-stream.d.ts +55 -0
- package/dist/server/page-stream.js +121 -0
- package/dist/server/payload.d.ts +6 -0
- package/dist/server/payload.js +22 -0
- package/dist/server/personal-response.d.ts +6 -0
- package/dist/server/personal-response.js +13 -0
- package/dist/server/props-wire.d.ts +56 -0
- package/dist/server/props-wire.js +178 -0
- package/dist/server/rate-limit.d.ts +22 -0
- package/dist/server/rate-limit.js +63 -0
- package/dist/server/region-css.d.ts +5 -0
- package/dist/server/region-css.js +30 -0
- package/dist/server/region-endpoint.d.ts +35 -0
- package/dist/server/region-endpoint.js +142 -0
- package/dist/server/region-props.d.ts +6 -0
- package/dist/server/region-props.js +44 -0
- package/dist/server/remote-seed-gate.d.ts +50 -0
- package/dist/server/remote-seed-gate.js +66 -0
- package/dist/server/render-cache.d.ts +26 -0
- package/dist/server/render-cache.js +51 -0
- package/dist/server/render-page.d.ts +51 -0
- package/dist/server/render-page.js +176 -0
- package/dist/server/render-region-html.d.ts +2 -0
- package/dist/server/render-region-html.js +19 -0
- package/dist/server/request-stats.d.ts +174 -0
- package/dist/server/request-stats.js +54 -0
- package/dist/server/reversible.d.ts +83 -0
- package/dist/server/reversible.js +994 -0
- package/dist/server/seed-shape.d.ts +22 -0
- package/dist/server/seed-shape.js +24 -0
- package/dist/server/shared-cache.d.ts +35 -0
- package/dist/server/shared-cache.js +104 -0
- package/dist/server/signing-key.d.ts +11 -0
- package/dist/server/signing-key.js +47 -0
- package/dist/server/sized-lru.d.ts +20 -0
- package/dist/server/sized-lru.js +68 -0
- package/dist/server/split-regions.d.ts +81 -0
- package/dist/server/split-regions.js +368 -0
- package/dist/server/stream-regions.d.ts +5 -0
- package/dist/server/stream-regions.js +36 -0
- package/dist/shared-state.d.ts +7 -0
- package/dist/shared-state.js +152 -0
- package/dist/shims/app-environment.d.ts +4 -0
- package/dist/shims/app-environment.js +6 -0
- package/dist/shims/app-navigation.d.ts +33 -0
- package/dist/shims/app-navigation.js +119 -0
- package/dist/shims/app-paths.d.ts +6 -0
- package/dist/shims/app-paths.js +18 -0
- package/dist/shims/app-state.svelte.d.ts +23 -0
- package/dist/shims/app-state.svelte.js +50 -0
- package/dist/shims/app-stores.d.ts +21 -0
- package/dist/shims/app-stores.js +54 -0
- package/dist/shims/kit-bridge.d.ts +29 -0
- package/dist/shims/kit-bridge.js +7 -0
- package/dist/shims/kit-remote/client-stub.d.ts +27 -0
- package/dist/shims/kit-remote/client-stub.js +43 -0
- package/dist/shims/kit-remote/paths-internal-stub.d.ts +3 -0
- package/dist/shims/kit-remote/paths-internal-stub.js +5 -0
- package/dist/shims/kit-remote/remote-cache.d.ts +49 -0
- package/dist/shims/kit-remote/remote-cache.js +73 -0
- package/dist/shims/kit-remote/state-stub.d.ts +8 -0
- package/dist/shims/kit-remote/state-stub.js +8 -0
- package/dist/shims/page-store.svelte.d.ts +39 -0
- package/dist/shims/page-store.svelte.js +77 -0
- package/dist/source.d.ts +29 -0
- package/dist/source.js +87 -0
- package/dist/store-transport.d.ts +86 -0
- package/dist/store-transport.js +229 -0
- package/dist/tool-fetches.d.ts +5 -0
- package/dist/tool-fetches.js +41 -0
- package/dist/transport.d.ts +48 -0
- package/dist/transport.js +181 -0
- package/dist/unscoped-css.d.ts +26 -0
- package/dist/unscoped-css.js +53 -0
- package/dist/vite/dev-maps.d.ts +33 -0
- package/dist/vite/dev-maps.js +140 -0
- package/dist/vite/index.d.ts +19 -0
- package/dist/vite/index.js +820 -0
- package/dist/vite/kit-dirs.d.ts +7 -0
- package/dist/vite/kit-dirs.js +67 -0
- package/dist/vite/late-island.d.ts +25 -0
- package/dist/vite/late-island.js +33 -0
- package/dist/vite/module-map.d.ts +8 -0
- package/dist/vite/module-map.js +144 -0
- package/dist/vite/options.d.ts +208 -0
- package/dist/vite/options.js +74 -0
- package/dist/vite/package-files.d.ts +3 -0
- package/dist/vite/package-files.js +84 -0
- package/dist/vite/paths.d.ts +45 -0
- package/dist/vite/paths.js +112 -0
- package/dist/vite/plugin-api.d.ts +166 -0
- package/dist/vite/plugin-api.js +212 -0
- package/dist/vite/profiler-maps.d.ts +23 -0
- package/dist/vite/profiler-maps.js +178 -0
- package/dist/vite/server-sourcemaps.d.ts +8 -0
- package/dist/vite/server-sourcemaps.js +80 -0
- package/dist/vite/sourcemaps.d.ts +18 -0
- package/dist/vite/sourcemaps.js +80 -0
- package/dist/vite/style-preprocess.d.ts +2 -0
- package/dist/vite/style-preprocess.js +96 -0
- package/package.json +180 -6
|
@@ -0,0 +1,2157 @@
|
|
|
1
|
+
import { without_comments as without_comments$1, without_runtime_marks } from "../runtime/beacon.js";
|
|
2
|
+
import { hazard_fix, hazard_words } from "./hydration-hazards.js";
|
|
3
|
+
import { OWN_PACKAGE_NAMES } from "../own-package.js";
|
|
4
|
+
import "../runtime/concurrency.js";
|
|
5
|
+
import { fn_label } from "./cpu.js";
|
|
6
|
+
import { third_party, third_party_findings } from "./third-party.js";
|
|
7
|
+
// src/insights/page-insights.ts
|
|
8
|
+
/**
|
|
9
|
+
* The Page tab's brain — pure, DOM-free, so the planted-problem answer key and the unit tests run it
|
|
10
|
+
* on plain data. In: what the browser saw of this document (runtime/beacon.ts `beacon_page()`), the
|
|
11
|
+
* regions on the page (read off the DOM by the tab), and the bus's hydration failures. Out: the
|
|
12
|
+
* vitals rated, one row per island (wake → module → turn → hydrated, with what hit it), and the
|
|
13
|
+
* findings — each naming the island(s) it is about, so the tab can light them up on the page.
|
|
14
|
+
*
|
|
15
|
+
* Every finding is something the browser MEASURED on this visit; nothing is guessed from source.
|
|
16
|
+
* Thresholds are the public web-vitals ones; the island ones are chosen so a healthy page shows
|
|
17
|
+
* none (the lab page's decoys hold that line).
|
|
18
|
+
*/
|
|
19
|
+
/** markup as a visitor's eyes compare it: no comments (Svelte's re-anchored block markers), no
|
|
20
|
+
* marks the runtime itself set on the regions inside */
|
|
21
|
+
const without_comments = (html) => without_runtime_marks(without_comments$1(html));
|
|
22
|
+
/** when an island's own work ended: its effects, when they ran after `hydrate()` returned */
|
|
23
|
+
const work_end = (i) => Math.max(i.done, i.fx ?? 0);
|
|
24
|
+
/** The runtime runs this many hole requests at once (runtime/session.ts `server_gate`). */
|
|
25
|
+
const HOLE_GATE = 3;
|
|
26
|
+
/**
|
|
27
|
+
* The page seed's defer ids → the `page.data` key each stands for (top level and one level down):
|
|
28
|
+
* the seed (`application/ogygia-page`, the flat devalue form) holds a defer marker `[name, i]` per
|
|
29
|
+
* streamed promise, the marker's payload the id the streamed resolve script settles. From the whole
|
|
30
|
+
* document's HTML or the seed script's own text. indexOf only.
|
|
31
|
+
*/
|
|
32
|
+
function defer_keys(html, marker = "OgygiaDefer") {
|
|
33
|
+
const out = /* @__PURE__ */ new Map();
|
|
34
|
+
let text = html;
|
|
35
|
+
const at = html.indexOf("application/ogygia-page");
|
|
36
|
+
if (at !== -1) {
|
|
37
|
+
const open = html.indexOf(">", at);
|
|
38
|
+
const close = html.indexOf("<\/script", open);
|
|
39
|
+
if (open === -1 || close === -1) return out;
|
|
40
|
+
text = html.slice(open + 1, close);
|
|
41
|
+
}
|
|
42
|
+
let arr;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(text);
|
|
45
|
+
if (!Array.isArray(parsed)) return out;
|
|
46
|
+
arr = parsed;
|
|
47
|
+
} catch {
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const id_of = (i) => {
|
|
51
|
+
const v = typeof i === "number" ? arr[i] : void 0;
|
|
52
|
+
if (!Array.isArray(v) || v[0] !== marker) return null;
|
|
53
|
+
let p = arr[v[1]];
|
|
54
|
+
if (Array.isArray(p)) p = arr[p[0]];
|
|
55
|
+
return typeof p === "number" ? p : null;
|
|
56
|
+
};
|
|
57
|
+
const root = arr[0];
|
|
58
|
+
const data = root && typeof root.data === "number" ? arr[root.data] : void 0;
|
|
59
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return out;
|
|
60
|
+
for (const [key, idx] of Object.entries(data)) {
|
|
61
|
+
const id = id_of(idx);
|
|
62
|
+
if (id !== null) {
|
|
63
|
+
out.set(id, key);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const inner = typeof idx === "number" ? arr[idx] : void 0;
|
|
67
|
+
if (inner && typeof inner === "object" && !Array.isArray(inner)) for (const [k2, i2] of Object.entries(inner)) {
|
|
68
|
+
const id2 = id_of(i2);
|
|
69
|
+
if (id2 !== null) out.set(id2, `${key}.${k2}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
const LIMITS = {
|
|
75
|
+
ttfb: [
|
|
76
|
+
800,
|
|
77
|
+
1800,
|
|
78
|
+
"TTFB"
|
|
79
|
+
],
|
|
80
|
+
fcp: [
|
|
81
|
+
1800,
|
|
82
|
+
3e3,
|
|
83
|
+
"FCP"
|
|
84
|
+
],
|
|
85
|
+
lcp: [
|
|
86
|
+
2500,
|
|
87
|
+
4e3,
|
|
88
|
+
"LCP"
|
|
89
|
+
],
|
|
90
|
+
cls: [
|
|
91
|
+
.1,
|
|
92
|
+
.25,
|
|
93
|
+
"CLS"
|
|
94
|
+
],
|
|
95
|
+
inp: [
|
|
96
|
+
200,
|
|
97
|
+
500,
|
|
98
|
+
"INP"
|
|
99
|
+
]
|
|
100
|
+
};
|
|
101
|
+
/** The browser a page ran in, by name and major version (`Safari 18`, `Chrome 141`, `Firefox 133`,
|
|
102
|
+
* `Edge 141`), from its user-agent string by plain search; null when it says nothing known. Some
|
|
103
|
+
* findings are one engine's (Safari reports no layout shifts; a preload one engine reuses another
|
|
104
|
+
* fetches again): the profiler's report and the Page tab name which. */
|
|
105
|
+
function browser_of(ua) {
|
|
106
|
+
if (!ua) return null;
|
|
107
|
+
const major = (token) => {
|
|
108
|
+
const at = ua.indexOf(token);
|
|
109
|
+
if (at === -1) return "";
|
|
110
|
+
let v = "";
|
|
111
|
+
for (let i = at + token.length; i < ua.length; i++) {
|
|
112
|
+
const c = ua.charCodeAt(i);
|
|
113
|
+
if (c < 48 || c > 57) break;
|
|
114
|
+
v += ua[i];
|
|
115
|
+
}
|
|
116
|
+
return v ? " " + v : "";
|
|
117
|
+
};
|
|
118
|
+
if (ua.includes("Edg/")) return "Edge" + major("Edg/");
|
|
119
|
+
if (ua.includes("OPR/")) return "Opera" + major("OPR/");
|
|
120
|
+
if (ua.includes("Firefox/")) return "Firefox" + major("Firefox/");
|
|
121
|
+
if (ua.includes("Chrome/")) return "Chrome" + major("Chrome/");
|
|
122
|
+
if (ua.includes("Safari/") && ua.includes("Version/")) return "Safari" + major("Version/");
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/** The engine family a user agent runs on: what makes two browsers measure a page differently (Edge
|
|
126
|
+
* and Chrome are one; Safari reports no layout shifts and refetches a preload Chromium reuses). */
|
|
127
|
+
function engine_of(ua) {
|
|
128
|
+
const b = browser_of(ua);
|
|
129
|
+
if (!b) return null;
|
|
130
|
+
if (b.startsWith("Safari")) return "webkit";
|
|
131
|
+
if (b.startsWith("Firefox")) return "gecko";
|
|
132
|
+
return "chromium";
|
|
133
|
+
}
|
|
134
|
+
/** The packages a dev server's `_metadata.json` `optimized` keys name: `svelte/internal/client` →
|
|
135
|
+
* `svelte`, `@scope/lib/x` → `@scope/lib`, `ogygia > @neodrag/svelte` (a dependency's dependency) →
|
|
136
|
+
* `@neodrag/svelte`, each once. */
|
|
137
|
+
function prebundled_names(keys) {
|
|
138
|
+
const names = /* @__PURE__ */ new Set();
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const at = key.lastIndexOf(" > ");
|
|
141
|
+
const parts = (at === -1 ? key : key.slice(at + 3)).split("/");
|
|
142
|
+
names.add(parts[0].startsWith("@") ? `${parts[0]}/${parts[1] ?? ""}` : parts[0]);
|
|
143
|
+
}
|
|
144
|
+
return [...names];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* ONE PACKAGE, TWO COPIES ON THE DEV SERVER: of the scripts the page loaded, the ones served raw from a
|
|
148
|
+
* package's folder (`/node_modules/<pkg>/…`, not `.vite/`) whose package Vite also pre-bundled — each
|
|
149
|
+
* package once, with how many raw files and one of them. String search: it runs on the Page tab tick.
|
|
150
|
+
* ogygia is one package under its two names (`ogygia` re-exports `@ogygia/core`): an app pre-bundles
|
|
151
|
+
* `ogygia/…` while the compiler's imports load core's files raw.
|
|
152
|
+
*/
|
|
153
|
+
function dev_two_copies(urls, prebundled) {
|
|
154
|
+
const name_of = (pkg) => OWN_PACKAGE_NAMES.includes(pkg) ? "ogygia" : pkg;
|
|
155
|
+
const pre = new Set(prebundled.map(name_of));
|
|
156
|
+
const by = /* @__PURE__ */ new Map();
|
|
157
|
+
for (const u of urls) {
|
|
158
|
+
const nm = u.lastIndexOf("/node_modules/");
|
|
159
|
+
if (nm === -1 || u.includes("/node_modules/.vite/")) continue;
|
|
160
|
+
const parts = u.slice(nm + 14).split("?")[0].split("/");
|
|
161
|
+
const folder = parts[0].startsWith("@") ? `${parts[0]}/${parts[1] ?? ""}` : parts[0];
|
|
162
|
+
const pkg = name_of(folder);
|
|
163
|
+
if (!pre.has(pkg)) continue;
|
|
164
|
+
const g = by.get(pkg);
|
|
165
|
+
if (g) g.raw++;
|
|
166
|
+
else by.set(pkg, {
|
|
167
|
+
pkg,
|
|
168
|
+
raw: 1,
|
|
169
|
+
example: parts.slice(folder.startsWith("@") ? 2 : 1).join("/")
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return [...by.values()].sort((a, b) => b.raw - a.raw);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* THE INLINE THRESHOLD THAT TAKES THE SMALL SHEETS OFF THE PAINT: Kit inlines its route stylesheets
|
|
176
|
+
* under `inlineStyleThreshold` (ogygia's island sheets follow the same setting), as `<style>` in the
|
|
177
|
+
* HTML — no request to wait on. Of the blocking build stylesheets (`/_app/immutable/`), the smallest
|
|
178
|
+
* first, while each is 8 KB or less and the HTML grows by 24 KB at most: the threshold that inlines
|
|
179
|
+
* them (just over the largest, in whole KB), how many, how many bytes. Null under two sheets. The
|
|
180
|
+
* profiler's page weight (`style`) and the Page tab's resources (`css`) both ask it.
|
|
181
|
+
*/
|
|
182
|
+
function inline_threshold_tune(blocking) {
|
|
183
|
+
const sheets = blocking.filter((x) => (x.kind === "style" || x.kind === "css") && x.url.includes("/_app/immutable/") && x.bytes > 0).sort((a, b) => a.bytes - b.bytes);
|
|
184
|
+
let files = 0;
|
|
185
|
+
let bytes = 0;
|
|
186
|
+
let largest = 0;
|
|
187
|
+
for (const s of sheets) {
|
|
188
|
+
if (s.bytes > 8192 || bytes + s.bytes > 24576) break;
|
|
189
|
+
files++;
|
|
190
|
+
bytes += s.bytes;
|
|
191
|
+
largest = s.bytes;
|
|
192
|
+
}
|
|
193
|
+
if (files < 2) return null;
|
|
194
|
+
return {
|
|
195
|
+
threshold: (Math.floor(largest / 1024) + 1) * 1024,
|
|
196
|
+
files,
|
|
197
|
+
bytes
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** the named islands' own lines that draw differently in the browser (the dev server's reading) */
|
|
201
|
+
function hazards_of(page, names) {
|
|
202
|
+
const want = new Set(names);
|
|
203
|
+
return (page.island_code ?? []).filter((c) => want.has(c.name)).flatMap((c) => c.hazards ?? []);
|
|
204
|
+
}
|
|
205
|
+
/** text sent uncompressed: named past this many bytes in all (the HTML and the files), a warning past
|
|
206
|
+
* the second (on a slow connection, ~100 KB uncompressed is most of a second more) */
|
|
207
|
+
const UNCOMPRESSED_FINDING = 20480;
|
|
208
|
+
const UNCOMPRESSED_WARN = 102400;
|
|
209
|
+
/** eager islands below the first screen whose own code is this many wire bytes: a warning */
|
|
210
|
+
const EAGER_BELOW_WARN = 51200;
|
|
211
|
+
/** …and under this, its bytes are not the point: its hydration is */
|
|
212
|
+
const EAGER_BELOW_SMALL = 4096;
|
|
213
|
+
/** a shift counts against an island when it lands within this long after the island hydrated */
|
|
214
|
+
const SHIFT_WINDOW_MS = 600;
|
|
215
|
+
/** a hydrate step this long is a long task of its own */
|
|
216
|
+
const LONG_HYDRATE_MS = 50;
|
|
217
|
+
/** waiting this long for a turn after the module arrived */
|
|
218
|
+
const LONG_QUEUE_MS = 150;
|
|
219
|
+
/** a hole on the first screen whose fallback showed this long (a skeleton a visitor stares at) */
|
|
220
|
+
const SLOW_HOLE_MS = 1e3;
|
|
221
|
+
/** a wait with nothing ahead this long is the scheduler's own (its viewport snapshot waits at most
|
|
222
|
+
* 48 ms by design, and its first report lands within a frame or two) */
|
|
223
|
+
const HELD_IDLE_MS = 40;
|
|
224
|
+
/** an in-app navigation this long before the new page shows is felt (a click that seems to do
|
|
225
|
+
* nothing); twice past a second, a warning */
|
|
226
|
+
const SLOW_NAV_MS = 400;
|
|
227
|
+
const SLOW_NAV_WARN_MS = 1e3;
|
|
228
|
+
/** a module load this slow */
|
|
229
|
+
const SLOW_LOAD_MS = 800;
|
|
230
|
+
/** an above-the-fold island still asleep this long after the largest paint */
|
|
231
|
+
const LATE_MS = 1e3;
|
|
232
|
+
/** a wake-at-load island still asleep this long after the load event */
|
|
233
|
+
const NEVER_MS = 3e3;
|
|
234
|
+
/** a page of this many elements or more is named (the beacon reports from the same count) */
|
|
235
|
+
const DOM_LARGE = 1500;
|
|
236
|
+
/** early by less than this is a tie, not a lost click */
|
|
237
|
+
const EARLY_SLACK_MS = 8;
|
|
238
|
+
const EAGER = /* @__PURE__ */ new Set(["load", "idle"]);
|
|
239
|
+
const round = (n, d = 1) => {
|
|
240
|
+
const k = 10 ** d;
|
|
241
|
+
return Math.round(n * k) / k;
|
|
242
|
+
};
|
|
243
|
+
function rate(key, value) {
|
|
244
|
+
const [good, poor] = LIMITS[key];
|
|
245
|
+
return value <= good ? "good" : value < poor ? "fair" : "poor";
|
|
246
|
+
}
|
|
247
|
+
/** Two markups that differ, and differ only in percent-encoding (`a/b` against `a%2Fb`): equal
|
|
248
|
+
* once both are decoded. A stray `%` that is not an escape (text reading "50%") makes it `false`. */
|
|
249
|
+
function encoding_only(a, b) {
|
|
250
|
+
a = without_comments(a);
|
|
251
|
+
b = without_comments(b);
|
|
252
|
+
if (a === b || !a.includes("%") && !b.includes("%")) return false;
|
|
253
|
+
try {
|
|
254
|
+
return decodeURIComponent(a) === decodeURIComponent(b);
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Where two strings first differ, as a short "server … / now …" pair (the markup-changed proof). */
|
|
260
|
+
function first_difference(a, b, span = 60) {
|
|
261
|
+
a = without_comments(a);
|
|
262
|
+
b = without_comments(b);
|
|
263
|
+
if (a === b) return null;
|
|
264
|
+
const n = Math.min(a.length, b.length);
|
|
265
|
+
let i = 0;
|
|
266
|
+
while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
|
|
267
|
+
let from = Math.max(0, i - 24);
|
|
268
|
+
for (let j = i - 1; j >= Math.max(0, i - 24); j--) {
|
|
269
|
+
const c = a[j];
|
|
270
|
+
if (c === " " || c === ">") {
|
|
271
|
+
from = j + 1;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
at: i,
|
|
277
|
+
server: a.slice(from, i + span),
|
|
278
|
+
now: b.slice(from, i + span)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/** At the first difference `at`: an attribute of a CUSTOM ELEMENT empty on the server (`name=""`)
|
|
282
|
+
* that the browser holds as `"true"` / `"false"` — written bare (Svelte hands a custom element its
|
|
283
|
+
* attributes as values, and a bare one's is `true`; measured: `={true}` and `=""` agree on both
|
|
284
|
+
* sides). Its name, the element's tag, the browser's value. */
|
|
285
|
+
function boolean_attr_at(server, now, at) {
|
|
286
|
+
if (at < 2 || server.charCodeAt(at - 1) !== 34 || server.charCodeAt(at - 2) !== 61 || server.charCodeAt(at) !== 34) return null;
|
|
287
|
+
const v = now.startsWith("true\"", at) ? "true" : now.startsWith("false\"", at) ? "false" : null;
|
|
288
|
+
if (!v) return null;
|
|
289
|
+
let s = at - 2;
|
|
290
|
+
while (s > 0) {
|
|
291
|
+
const c = server.charCodeAt(s - 1);
|
|
292
|
+
if (c === 32 || c === 10 || c === 9 || c === 60 || c === 34) break;
|
|
293
|
+
s--;
|
|
294
|
+
}
|
|
295
|
+
const name = server.slice(s, at - 2);
|
|
296
|
+
const lt = server.lastIndexOf("<", s);
|
|
297
|
+
if (!name || lt === -1) return null;
|
|
298
|
+
let e = lt + 1;
|
|
299
|
+
while (e < s && server.charCodeAt(e) !== 32 && server.charCodeAt(e) !== 10 && server.charCodeAt(e) !== 9) e++;
|
|
300
|
+
const tag = server.slice(lt + 1, e);
|
|
301
|
+
return tag.indexOf("-") !== -1 ? {
|
|
302
|
+
name,
|
|
303
|
+
tag,
|
|
304
|
+
now: v
|
|
305
|
+
} : null;
|
|
306
|
+
}
|
|
307
|
+
function analyze_page(page, regions, failures = [], now = Infinity, cpu = null) {
|
|
308
|
+
/** " — mostly in `fn (file:line)`" from the CPU trace, for a finding about this island (or '') */
|
|
309
|
+
const why_cpu = (fp) => {
|
|
310
|
+
const f = fp ? cpu?.islands[fp]?.top[0] : cpu?.outside.top[0];
|
|
311
|
+
if (!f || f.self_ms < 5) return "";
|
|
312
|
+
if (fp && f.name === name_of(fp) && f.file) return `; mostly its own code (${f.file.slice(f.file.lastIndexOf("/") + 1)}${f.line !== null ? `:${f.line}` : ""})`;
|
|
313
|
+
return `; mostly ${fn_label(f)}`;
|
|
314
|
+
};
|
|
315
|
+
/** " — mostly <script>" from the long animation frames, when no CPU trace names it (or '') */
|
|
316
|
+
const why_script = () => {
|
|
317
|
+
const top = page.visit?.scripts?.[0];
|
|
318
|
+
if (!top || top.ms < 30) return "";
|
|
319
|
+
const q = top.url.indexOf("?");
|
|
320
|
+
const path = q === -1 ? top.url : top.url.slice(0, q);
|
|
321
|
+
let where = path;
|
|
322
|
+
try {
|
|
323
|
+
const u = new URL(path);
|
|
324
|
+
where = (page.visit?.origin && u.origin === page.visit.origin ? "" : u.host) + u.pathname;
|
|
325
|
+
} catch {}
|
|
326
|
+
return `; mostly ${where} (${Math.round(top.ms)} ms)`;
|
|
327
|
+
};
|
|
328
|
+
const findings = [];
|
|
329
|
+
const by_fp = /* @__PURE__ */ new Map();
|
|
330
|
+
for (const r of regions) if (r.fp && !by_fp.has(r.fp)) by_fp.set(r.fp, r);
|
|
331
|
+
const name_of = (fp, entry) => fp && by_fp.get(fp)?.name || entry || fp || "an island";
|
|
332
|
+
const vh = page.visit?.viewport?.[1] ?? 0;
|
|
333
|
+
const vw = page.visit?.viewport?.[0] ?? 0;
|
|
334
|
+
const vitals = [];
|
|
335
|
+
for (const key of [
|
|
336
|
+
"ttfb",
|
|
337
|
+
"fcp",
|
|
338
|
+
"lcp",
|
|
339
|
+
"cls",
|
|
340
|
+
"inp"
|
|
341
|
+
]) {
|
|
342
|
+
const v = page.vitals[key];
|
|
343
|
+
if (typeof v !== "number" || !Number.isFinite(v)) continue;
|
|
344
|
+
vitals.push({
|
|
345
|
+
key,
|
|
346
|
+
label: LIMITS[key][2],
|
|
347
|
+
value: v,
|
|
348
|
+
rating: rate(key, v)
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
const latest = /* @__PURE__ */ new Map();
|
|
352
|
+
for (const i of page.islands) latest.set(i.fp, i);
|
|
353
|
+
const island_tops = /* @__PURE__ */ new Map();
|
|
354
|
+
for (const r of regions) if (r.kind === "island" && typeof r.top === "number") (island_tops.get(r.name) ?? island_tops.set(r.name, []).get(r.name)).push(r.top);
|
|
355
|
+
const sheet_of = (url) => {
|
|
356
|
+
const q = url.indexOf(".svelte?");
|
|
357
|
+
return q === -1 ? null : url.slice(url.lastIndexOf("/", q) + 1, q);
|
|
358
|
+
};
|
|
359
|
+
const dev_css = page.dev ? (page.visit?.resources ?? []).filter((r) => r.url.includes("type=style")).map((r) => ({
|
|
360
|
+
end: r.end,
|
|
361
|
+
of: sheet_of(r.url)
|
|
362
|
+
})) : [];
|
|
363
|
+
const by_dev_css = (t, name, top) => dev_css.some((e) => {
|
|
364
|
+
if (e.end > t + 5 || t - e.end > 250) return false;
|
|
365
|
+
const tops = e.of && e.of !== name ? island_tops.get(e.of) : void 0;
|
|
366
|
+
return !(tops && typeof top === "number" && tops.every((x) => x > top));
|
|
367
|
+
});
|
|
368
|
+
const rows = [];
|
|
369
|
+
for (const i of latest.values()) {
|
|
370
|
+
const fact = by_fp.get(i.fp);
|
|
371
|
+
const lo = i.turn ?? i.loaded;
|
|
372
|
+
const end = work_end(i);
|
|
373
|
+
let shift = 0;
|
|
374
|
+
for (const s of page.shifts) if (s.fp === i.fp && s.t >= i.done - 16 && s.t <= end + SHIFT_WINDOW_MS && !by_dev_css(s.t, fact?.name ?? "", fact?.top)) shift += s.value;
|
|
375
|
+
let lt = 0;
|
|
376
|
+
for (const t of page.longtasks) {
|
|
377
|
+
const a = Math.max(t.t, lo);
|
|
378
|
+
const b = Math.min(t.t + t.ms, end);
|
|
379
|
+
if (b > a) lt += b - a;
|
|
380
|
+
}
|
|
381
|
+
const first = page.firsts.find((f) => f.fp === i.fp);
|
|
382
|
+
const early = first && first.t < i.done - EARLY_SLACK_MS ? i.done - first.t : null;
|
|
383
|
+
rows.push({
|
|
384
|
+
fp: i.fp,
|
|
385
|
+
name: name_of(i.fp, i.entry),
|
|
386
|
+
wake: fact?.wake ?? "",
|
|
387
|
+
load_ms: round(Math.max(0, i.loaded - i.t0)),
|
|
388
|
+
queue_ms: i.turn !== void 0 ? round(Math.max(0, i.turn - i.loaded)) : null,
|
|
389
|
+
hydrate_ms: round(Math.max(0, end - lo)),
|
|
390
|
+
t0: i.t0,
|
|
391
|
+
done: end,
|
|
392
|
+
recovered: !!i.recovered,
|
|
393
|
+
changed: !!i.changed,
|
|
394
|
+
shift: round(shift, 4),
|
|
395
|
+
longtask_ms: round(lt),
|
|
396
|
+
early_ms: early === null ? null : round(early),
|
|
397
|
+
below_fold: !!(fact && vh && typeof fact.top === "number" && fact.top >= vh),
|
|
398
|
+
cpu_ms: cpu ? cpu.islands[i.fp]?.ms ?? 0 : null
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
rows.sort((a, b) => a.t0 - b.t0);
|
|
402
|
+
for (const f of failures) {
|
|
403
|
+
let why = f.message.split("\n")[0].trim();
|
|
404
|
+
while (why.endsWith(".")) why = why.slice(0, -1);
|
|
405
|
+
findings.push({
|
|
406
|
+
code: "hydrate-failed",
|
|
407
|
+
severity: "error",
|
|
408
|
+
message: `${name_of(f.fp)} failed to hydrate: ${clip(why)}. It shows its server HTML and does nothing.`,
|
|
409
|
+
fix: "Open the browser console for the stack; the island module threw while loading or mounting.",
|
|
410
|
+
fps: f.fp ? [f.fp] : []
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const all_recovered = rows.filter((r) => r.recovered);
|
|
414
|
+
const touched_page = !!page.restore_events?.length;
|
|
415
|
+
const same = touched_page ? [] : all_recovered.filter((r) => {
|
|
416
|
+
const i = page.islands.find((x) => x.fp === r.fp);
|
|
417
|
+
return !r.changed && !i?.healed && i?.ssr_bytes !== void 0;
|
|
418
|
+
});
|
|
419
|
+
const rewired = same.filter((r) => page.islands.find((x) => x.fp === r.fp)?.markers);
|
|
420
|
+
if (rewired.length) {
|
|
421
|
+
const one = rewired.length === 1;
|
|
422
|
+
const m = page.islands.find((x) => x.fp === rewired[0].fp).markers;
|
|
423
|
+
findings.push({
|
|
424
|
+
code: "recovered",
|
|
425
|
+
severity: "error",
|
|
426
|
+
message: `${list(rewired.map((r) => r.name))} threw away the server HTML and rendered again in the browser (a flash and a double render): ${one ? "its" : "their"} markup arrived with Svelte's hidden block markers unpaired (${one ? "" : `${rewired[0].name}: `}${m[0]} opening, ${m[1]} closing), and a render always pairs them. Something rewrote ${one ? "it" : "them"} between the server and the browser; the markup the component drew is the same.`,
|
|
427
|
+
fix: "Find the rewrite: a transformPageChunk or HTML middleware after the render, an edge rewriter, or a proxy that strips HTML comments. Keep it out of ogygia-region subtrees (or run it before ogygia’s render), and let comments through.",
|
|
428
|
+
fps: rewired.map((r) => r.fp)
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const host_shape = same.filter((r) => !rewired.includes(r));
|
|
432
|
+
if (host_shape.length) {
|
|
433
|
+
const same = host_shape;
|
|
434
|
+
const one = same.length === 1;
|
|
435
|
+
findings.push({
|
|
436
|
+
code: "recovered",
|
|
437
|
+
severity: "error",
|
|
438
|
+
message: `${list(same.map((r) => r.name))} threw away the server HTML and rendered again in the browser (a flash and a double render) — and drew the same markup ${one ? "it" : "they"} threw away: only Svelte's hidden block markers disagreed. ${one ? "Its" : "Their"} code drew no other tree: the island's host and its server render disagree on structure (a wrapper of ogygia's), or a block ({#if}, {#each}) took another branch to the same output.`,
|
|
439
|
+
fix: "Report it to ogygia with this page and how the island is placed (keep:, inside a snippet, nested in another island): a host that hydrates in another shape than it renders is ogygia's bug. If a block's two branches draw the same thing, give them one branch.",
|
|
440
|
+
fps: same.map((r) => r.fp)
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const recovered = all_recovered.filter((r) => !same.includes(r));
|
|
444
|
+
if (recovered.length) {
|
|
445
|
+
const touched = recovered.some((r) => page.islands.find((i) => i.fp === r.fp)?.healed) || touched_page;
|
|
446
|
+
const lines = touched ? [] : hazards_of(page, recovered.map((r) => r.name));
|
|
447
|
+
findings.push({
|
|
448
|
+
code: "recovered",
|
|
449
|
+
severity: "error",
|
|
450
|
+
message: `${list(recovered.map((r) => r.name))} threw away the server HTML and rendered again in the browser (a flash and a double render). ` + (touched ? "Something changed the markup between the server and hydration." : "No script in the browser edited " + (recovered.length === 1 ? "it" : "them") + " before waking: either its HTML was rewritten on the way from the server, or the component itself drew a different tree in the browser." + (lines.length ? ` ${hazard_words(lines)}` : "")),
|
|
451
|
+
fix: touched ? "Look for a script that edits the page before islands wake (an A/B tool, a DOM injector), or markup the browser rewrites (invalid nesting like a <div> in a <p>)." : lines.length ? `${hazard_fix(lines)} If it is none of them, look for a rewrite on the way (a transformPageChunk, an HTML middleware or edge rewriter).` : "Look for a rewrite on the way (a transformPageChunk, an HTML middleware or edge rewriter), then in its components for what the browser sees differently: a block ({#if}, {#each}, {#await}) whose condition reads `window`, `Date`, `Math.random()` or storage; a context set only on the server (a csr=false layout's setContext); an `await` at the top of its script; or markup the browser rewrites (a <div> in a <p>).",
|
|
452
|
+
fps: recovered.map((r) => r.fp)
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const all_changed = rows.filter((r) => r.changed && !r.recovered);
|
|
456
|
+
const encoded = all_changed.filter((r) => {
|
|
457
|
+
const s = page.snapshots?.find((x) => x.fp === r.fp);
|
|
458
|
+
return !!s && encoding_only(s.ssr, s.hydrated);
|
|
459
|
+
});
|
|
460
|
+
const changed = all_changed.filter((r) => !encoded.includes(r));
|
|
461
|
+
if (encoded.length) {
|
|
462
|
+
const s = page.snapshots.find((x) => x.fp === encoded[0].fp);
|
|
463
|
+
const d = first_difference(s.ssr, s.hydrated);
|
|
464
|
+
findings.push({
|
|
465
|
+
code: "markup-encoded",
|
|
466
|
+
severity: "info",
|
|
467
|
+
message: `${list(encoded.map((r) => r.name))} rendered the same markup in the browser but for how a URL is encoded${d ? ` (server "${clip(d.server)}", browser "${clip(d.now)}")` : ""}: the same address, nothing changes on screen.`,
|
|
468
|
+
fix: "Nothing to fix for a visitor. If a test or a cache compares the HTML byte for byte, write the URL the same way on both sides (both encoded, or neither).",
|
|
469
|
+
fps: encoded.map((r) => r.fp)
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (changed.length) {
|
|
473
|
+
const snap = page.snapshots?.find((s) => s.fp === changed[0].fp);
|
|
474
|
+
const d = snap ? first_difference(snap.ssr, snap.hydrated) : null;
|
|
475
|
+
const bool = d && snap ? boolean_attr_at(without_comments(snap.ssr), without_comments(snap.hydrated), d.at) : null;
|
|
476
|
+
const changed_lines = hazards_of(page, changed.map((r) => r.name));
|
|
477
|
+
findings.push({
|
|
478
|
+
code: "markup-changed",
|
|
479
|
+
severity: "warn",
|
|
480
|
+
message: `${list(changed.map((r) => r.name))} rendered different markup in the browser than on the server, so the page changes as it wakes.` + (d ? ` First difference in ${changed[0].name}: server "${clip(d.server)}", browser "${clip(d.now)}".` : "") + (changed_lines.length && !bool ? ` ${hazard_words(changed_lines)}` : ""),
|
|
481
|
+
fix: bool ? `\`${bool.name}\` on <${bool.tag}> is written bare: the server writes ${bool.name}="" and Svelte's hydrate sets it to "${bool.now}" — Svelte hands a custom element its attributes as values, and a bare attribute's value is \`true\`. So the island's markup changes as it wakes, and CSS or the component reading the value sees two. Give it a value, the same on both sides: \`${bool.name}=""\` (or \`${bool.name}="true"\` when the component reads it as a flag).` : changed_lines.length ? hazard_fix(changed_lines) : "Render the same thing on both sides: move time, random values, and browser-only reads (window, localStorage) into an effect or an event, or pass them in as props.",
|
|
482
|
+
fps: changed.map((r) => r.fp)
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
const early = rows.filter((r) => r.early_ms !== null && r.wake !== "interaction");
|
|
486
|
+
const asleep_hit = page.firsts.map((f) => by_fp.get(f.fp)).filter((r) => !!r && r.kind === "island" && r.wake !== "interaction" && !r.hydrated && !latest.has(r.fp));
|
|
487
|
+
const early_labels = [...early.map((r) => `${r.name} (${Math.round(r.early_ms)} ms before it woke)`), ...asleep_hit.map((r) => `${r.name} (still asleep)`)];
|
|
488
|
+
if (early_labels.length) findings.push({
|
|
489
|
+
code: "early-click",
|
|
490
|
+
severity: "warn",
|
|
491
|
+
message: `A visitor reached ${list(early_labels)} before it was awake, so the first click did nothing.`,
|
|
492
|
+
fix: "Wake it sooner (a smaller module, or wake='load' if it is idle), or use wake='interaction' so the first click wakes it and is replayed.",
|
|
493
|
+
fps: [...early.map((r) => r.fp), ...asleep_hit.map((r) => r.fp)]
|
|
494
|
+
});
|
|
495
|
+
const shifted = rows.filter((r) => r.shift >= .01);
|
|
496
|
+
if (shifted.length) findings.push({
|
|
497
|
+
code: "hydration-shift",
|
|
498
|
+
severity: shifted.some((r) => r.shift >= .1) ? "warn" : "info",
|
|
499
|
+
message: `Hydrating ${list(shifted.map((r) => `${r.name} (CLS ${r.shift})`))} moved the layout: the island's size changed when it woke.` + (page.shifts_from === "boxes" ? " (Estimated: this browser reports no layout shifts, so each is the island's box before and after it woke, scored the way a layout shift is.)" : ""),
|
|
500
|
+
fix: "Render the island at its final size on the server (the same content, or a placeholder with a fixed height), so waking it changes nothing on screen.",
|
|
501
|
+
fps: shifted.map((r) => r.fp)
|
|
502
|
+
});
|
|
503
|
+
const heavy = rows.filter((r) => r.hydrate_ms >= LONG_HYDRATE_MS);
|
|
504
|
+
if (heavy.length) findings.push({
|
|
505
|
+
code: "long-hydrate",
|
|
506
|
+
severity: "warn",
|
|
507
|
+
message: `${list(heavy.map((r) => `${r.name} (${r.hydrate_ms} ms${why_cpu(r.fp)})`))} took one long task to hydrate. The page cannot respond to input while it runs.`,
|
|
508
|
+
fix: "Do less at mount: defer work the first paint does not need to an effect or idle callback, render long lists lazily, or split the island.",
|
|
509
|
+
fps: heavy.map((r) => r.fp)
|
|
510
|
+
});
|
|
511
|
+
const queued = rows.filter((r) => r.queue_ms !== null && r.queue_ms >= LONG_QUEUE_MS);
|
|
512
|
+
if (queued.length) {
|
|
513
|
+
const ahead = /* @__PURE__ */ new Map();
|
|
514
|
+
for (const q of queued) {
|
|
515
|
+
const a = q.t0 + q.load_ms;
|
|
516
|
+
const b = a + (q.queue_ms ?? 0);
|
|
517
|
+
for (const o of rows) {
|
|
518
|
+
if (o === q) continue;
|
|
519
|
+
const lo = o.done - o.hydrate_ms;
|
|
520
|
+
if (Math.min(b, o.done) - Math.max(a, lo) > 1) ahead.set(o.name, Math.max(ahead.get(o.name) ?? 0, o.hydrate_ms));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const top = [...ahead].sort((x, y) => y[1] - x[1]).slice(0, 3);
|
|
524
|
+
const held_for = /* @__PURE__ */ new Map();
|
|
525
|
+
for (const q of queued) {
|
|
526
|
+
if (!q.below_fold) continue;
|
|
527
|
+
const a = q.t0 + q.load_ms;
|
|
528
|
+
const b = a + (q.queue_ms ?? 0);
|
|
529
|
+
const spans = rows.filter((o) => o !== q && !o.below_fold).map((o) => ({
|
|
530
|
+
name: o.name,
|
|
531
|
+
s: o.t0,
|
|
532
|
+
e: o.t0 + o.load_ms,
|
|
533
|
+
failed: false
|
|
534
|
+
}));
|
|
535
|
+
for (const f of failures) {
|
|
536
|
+
if (!f.span || !f.fp) continue;
|
|
537
|
+
const top_px = by_fp.get(f.fp)?.top;
|
|
538
|
+
if (typeof top_px === "number" && vh && top_px >= vh) continue;
|
|
539
|
+
spans.push({
|
|
540
|
+
name: name_of(f.fp),
|
|
541
|
+
s: f.span[0],
|
|
542
|
+
e: f.span[1],
|
|
543
|
+
failed: true
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
for (const sp of spans) {
|
|
547
|
+
const hit = Math.min(b, sp.e) - Math.max(a, sp.s);
|
|
548
|
+
if (hit >= 50) held_for.set(sp.name, {
|
|
549
|
+
ms: Math.max(held_for.get(sp.name)?.ms ?? 0, hit),
|
|
550
|
+
failed: sp.failed
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const holders = [...held_for].sort((x, y) => y[1].ms - x[1].ms).slice(0, 3);
|
|
555
|
+
const held_ms = holders[0]?.[1].ms ?? 0;
|
|
556
|
+
findings.push({
|
|
557
|
+
code: "queued",
|
|
558
|
+
severity: "info",
|
|
559
|
+
message: `${list(queued.map((r) => `${r.name} (${r.queue_ms} ms)`))} had its code but waited for its turn` + (holders.length && held_ms >= (top[0]?.[1] ?? 0) ? `, held for ${list(holders.map(([n, h]) => `${n} (${Math.round(h.ms)} ms${h.failed ? ", which then failed" : ""})`))} on the first screen, still loading ${holders.length === 1 ? "its" : "their"} code: viewport first, the islands a visitor sees wake before the ones below.` : top.length && top[0][1] >= 10 ? `, behind ${list(top.map(([n, m]) => `${n} (${Math.round(m)} ms)`))}: islands hydrate one per task, viewport first.` : ": islands hydrate after the page is parsed and painted, one per task, viewport first."),
|
|
560
|
+
fix: holders.length && held_ms >= (top[0]?.[1] ?? 0) ? `The first-screen island${holders.length === 1 ? "" : "s"} loading slowly ${holders.length === 1 ? "is" : "are"} the cost: make ${holders.length === 1 ? "its" : "their"} code smaller or faster to load${holders.some(([, h]) => h.failed) ? " (and fix the one that failed)" : ""}. The hold is bounded (a second), so a module that never comes cannot stall the rest.` : "The islands ahead of it are the cost. Make them lighter, or move islands that are not needed at load to wake='visible' or 'idle'.",
|
|
561
|
+
fps: queued.map((r) => r.fp)
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
{
|
|
565
|
+
const f = explain_held_open(page, rows, name_of);
|
|
566
|
+
if (f) findings.push(f);
|
|
567
|
+
}
|
|
568
|
+
{
|
|
569
|
+
const gate = Math.max(page.visit?.nav?.dcl ?? 0, page.visit?.paints?.fcp ?? 0) + 20;
|
|
570
|
+
const steps = rows.map((o) => [o.done - o.hydrate_ms, o.done]);
|
|
571
|
+
const held = [];
|
|
572
|
+
for (const r of rows) {
|
|
573
|
+
if (r.queue_ms === null || r.queue_ms < HELD_IDLE_MS) continue;
|
|
574
|
+
const a = Math.max(r.t0 + r.load_ms, gate);
|
|
575
|
+
const b = r.t0 + r.load_ms + r.queue_ms;
|
|
576
|
+
if (b - a < HELD_IDLE_MS) continue;
|
|
577
|
+
const busy = [];
|
|
578
|
+
for (let i = 0; i < rows.length; i++) {
|
|
579
|
+
const o = rows[i];
|
|
580
|
+
if (o === r) continue;
|
|
581
|
+
busy.push([steps[i][0], steps[i][1]]);
|
|
582
|
+
if (r.below_fold && !o.below_fold) busy.push([o.t0, o.t0 + o.load_ms]);
|
|
583
|
+
}
|
|
584
|
+
if (r.below_fold) for (const f of failures) {
|
|
585
|
+
if (!f.span || !f.fp) continue;
|
|
586
|
+
const top = by_fp.get(f.fp)?.top;
|
|
587
|
+
if (typeof top === "number" && vh && top >= vh) continue;
|
|
588
|
+
busy.push([f.span[0], f.span[1]]);
|
|
589
|
+
}
|
|
590
|
+
for (const f of failures) if (f.span && typeof f.turn === "number" && f.turn <= f.span[1]) busy.push([f.turn, f.span[1]]);
|
|
591
|
+
for (const t of page.longtasks) busy.push([t.t, t.t + t.ms]);
|
|
592
|
+
busy.sort((x, y) => x[0] - y[0]);
|
|
593
|
+
let covered = 0;
|
|
594
|
+
let at = a;
|
|
595
|
+
for (const [s, e] of busy) {
|
|
596
|
+
const lo = Math.max(s, at);
|
|
597
|
+
const hi = Math.min(e, b);
|
|
598
|
+
if (hi > lo) {
|
|
599
|
+
covered += hi - lo;
|
|
600
|
+
at = hi;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const idle = b - a - covered;
|
|
604
|
+
if (idle >= HELD_IDLE_MS) held.push({
|
|
605
|
+
r,
|
|
606
|
+
idle
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
if (held.length) {
|
|
610
|
+
const med = [...held].sort((x, y) => x.idle - y.idle)[held.length >> 1].idle;
|
|
611
|
+
findings.push({
|
|
612
|
+
code: "held-idle",
|
|
613
|
+
severity: "warn",
|
|
614
|
+
message: `${held.length === 1 ? held[0].r.name : `${held.length} islands`} had ${held.length === 1 ? "its" : "their"} code but waited ${held.length === 1 ? Math.round(held[0].idle) : `about ${Math.round(med)}`} ms for ${held.length === 1 ? "its" : "their"} turn while no other island was hydrating and nothing on the first screen was still loading. Nothing on the page explains the wait: ogygia's scheduler held ${held.length === 1 ? "it" : "them"}.`,
|
|
615
|
+
fix: "This is ogygia’s own wait, not your code. Please report it with this page: the island names, their wake, and whether it happened on load or on a scroll.",
|
|
616
|
+
fps: held.slice(0, 20).map((h) => h.r.fp)
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const slow = rows.filter((r) => r.load_ms >= SLOW_LOAD_MS);
|
|
621
|
+
const compiled = slow.filter((r) => dev_compile_ms(page, r) >= r.load_ms * .5);
|
|
622
|
+
if (slow.length) findings.push({
|
|
623
|
+
code: "slow-module",
|
|
624
|
+
severity: "info",
|
|
625
|
+
message: `${list(slow.map((r) => `${r.name} (${r.load_ms} ms)`))} waited a long time for its code to arrive.` + (compiled.length ? ` ${compiled.length === slow.length ? "Most of the wait" : `For ${list(compiled.map((r) => r.name))}, most of it`} was the dev server compiling the files on this first request: reload for a warm reading.` : ""),
|
|
626
|
+
fix: "Check the island's imports in the Bytes tab: a big dependency, or a chain of imports loaded one after another. (Dev serves modules one by one; a build is faster.)",
|
|
627
|
+
fps: slow.map((r) => r.fp)
|
|
628
|
+
});
|
|
629
|
+
const eager_below = regions.filter((r) => r.kind === "island" && EAGER.has(r.wake) && !r.hidden && vh && typeof r.top === "number" && r.top >= vh);
|
|
630
|
+
if (eager_below.length) {
|
|
631
|
+
const own = /* @__PURE__ */ new Map();
|
|
632
|
+
for (const r of eager_below) if (r.own_bytes) own.set(r.name, r.own_bytes);
|
|
633
|
+
const own_total = [...own.values()].reduce((s, n) => s + n, 0);
|
|
634
|
+
const kb = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
635
|
+
const one = new Set(eager_below.map((r) => r.name)).size === 1;
|
|
636
|
+
findings.push({
|
|
637
|
+
code: "eager-offscreen",
|
|
638
|
+
severity: own_total >= EAGER_BELOW_WARN ? "warn" : "info",
|
|
639
|
+
message: `${list(eager_below.map((r) => r.name))} start${one ? "s" : ""} below the first screen but load${one ? "s" : ""} code at page load.` + (!own_total ? "" : own_total < EAGER_BELOW_SMALL ? ` Only ${kb(own_total)} of it is ${one ? "its own" : "theirs alone"} (the rest other islands load too): a later wake saves ${one ? "its" : "their"} hydration on the first load more than bytes.` : ` ${kb(own_total)} of it ${one ? "is its own" : "is theirs alone"} (no other island loads it): that much leaves the first load with a later wake.`),
|
|
640
|
+
fix: "Use wake='visible': its code loads when it scrolls into view, and the islands on the first screen wake sooner.",
|
|
641
|
+
fps: eager_below.map((r) => r.fp)
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
if (page.dev && page.prebundled?.length) {
|
|
645
|
+
const twice = dev_two_copies((page.visit?.resources ?? []).filter((r) => r.type === "script").map((r) => r.url), page.prebundled);
|
|
646
|
+
if (twice.length) {
|
|
647
|
+
const top = twice[0];
|
|
648
|
+
findings.push({
|
|
649
|
+
code: "dev-two-copies",
|
|
650
|
+
severity: "warn",
|
|
651
|
+
message: `${list(twice.map((t) => t.pkg))} load${twice.length === 1 ? "s" : ""} twice on the dev server: pre-bundled by Vite (.vite/deps) and raw from ${twice.length === 1 ? "its" : "their"} folder (${top.pkg}: ${top.raw} file${top.raw === 1 ? "" : "s"}, e.g. ${top.example}). A module that keeps state then has two copies: one side writes what the other never reads, and the bug shows only in dev.`,
|
|
652
|
+
fix: `Give ${twice.length === 1 ? "it" : "each"} one copy: add ${twice.map((t) => `'${t.pkg}'`).join(", ")} to \`optimizeDeps.exclude\` so every import is served raw, or to \`optimizeDeps.include\` with every path it is imported by. (ogygia excludes itself; check the app's own \`optimizeDeps.include\` does not list it.)`,
|
|
653
|
+
fps: []
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const broke = new Set(failures.map((f) => f.fp));
|
|
658
|
+
const eager_hidden = regions.filter((r) => r.kind === "island" && EAGER.has(r.wake) && r.hidden && !r.headless && !broke.has(r.fp));
|
|
659
|
+
const headless = regions.filter((r) => r.kind === "island" && EAGER.has(r.wake) && r.headless && !broke.has(r.fp) && (r.own_bytes ?? 0) >= EAGER_BELOW_SMALL);
|
|
660
|
+
if (headless.length) {
|
|
661
|
+
const own = /* @__PURE__ */ new Map();
|
|
662
|
+
for (const r of headless) own.set(r.name, r.own_bytes);
|
|
663
|
+
const names = [...own.keys()];
|
|
664
|
+
const one = names.length === 1;
|
|
665
|
+
const total = [...own.values()].reduce((s, n) => s + n, 0);
|
|
666
|
+
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
667
|
+
findings.push({
|
|
668
|
+
code: "headless-island",
|
|
669
|
+
severity: total >= EAGER_BELOW_WARN ? "warn" : "info",
|
|
670
|
+
message: `${list(names.map((n) => `${n} (${kb(own.get(n))} of its own)`))} render${one ? "s" : ""} nothing to see — only hidden markers — and load${one ? "s" : ""} ${one ? "its" : "their"} code at page load to run ${one ? "its" : "their"} effects.`,
|
|
671
|
+
fix: `Worth it if the effects must run at start. Otherwise ${headless.some((r) => r.wake === "load") ? "run them later (`wake: 'idle'`, after the page settles), or " : ""}move them into an island the visitor touches, where they load with it.`,
|
|
672
|
+
fps: headless.map((r) => r.fp)
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (eager_hidden.length) {
|
|
676
|
+
const names = [...new Set(eager_hidden.map((r) => r.name))];
|
|
677
|
+
const one = names.length === 1;
|
|
678
|
+
const query_of = /* @__PURE__ */ new Map();
|
|
679
|
+
for (const r of eager_hidden) if (r.shows_at && !query_of.has(r.name)) query_of.set(r.name, r.shows_at);
|
|
680
|
+
const shown = names.filter((n) => query_of.has(n));
|
|
681
|
+
const unknown = names.filter((n) => !query_of.has(n));
|
|
682
|
+
const own = /* @__PURE__ */ new Map();
|
|
683
|
+
for (const r of eager_hidden) if (r.own_bytes) own.set(r.name, r.own_bytes);
|
|
684
|
+
const own_total = [...own.values()].reduce((s, n) => s + n, 0);
|
|
685
|
+
const kb = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
686
|
+
const shows = shown.length ? [one ? `the page's CSS shows it only at ${query_of.get(shown[0])}` : `the page's CSS shows ${shown.map((n) => `${n} only at ${query_of.get(n)}`).join(", ")}`] : [];
|
|
687
|
+
const bare = unknown.length ? one ? "no box of its own, hidden by its CSS or empty" : `${list(unknown)} ${unknown.length === 1 ? "has" : "have"} no box of ${unknown.length === 1 ? "its" : "their"} own (hidden by CSS, or empty)` : "";
|
|
688
|
+
const first_q = shown.length ? query_of.get(shown[0]) : "";
|
|
689
|
+
findings.push({
|
|
690
|
+
code: "eager-hidden",
|
|
691
|
+
severity: own_total >= EAGER_BELOW_WARN ? "warn" : "info",
|
|
692
|
+
message: `${list(names)} draw${one ? "s" : ""} nothing on this screen (${vw ? `${vw} px wide` : "this size"}) but load${one ? "s" : ""} ${one ? "its" : "their"} code at page load: ${[...shows, ...bare ? [bare] : []].join("; ")}.` + (own_total ? ` ${kb(own_total)} of that code ${one ? "is its own" : "is theirs alone"}.` : ""),
|
|
693
|
+
fix: (shown.length === 1 ? `Wake ${one ? "it" : shown[0]} by that media query (\`with { wake: '${first_q}' }\`): the code loads only on the screens that show it, and wakes there as the screen changes.` : shown.length ? `Wake each by its media query (${shown.map((n) => `${n}: \`with { wake: '${query_of.get(n)}' }\``).join(", ")}): the code loads only on the screens that show them, and wakes there as the screen changes.` : "") + (unknown.length ? `${shown.length ? ` For ${list(unknown)}: if` : "If"} it shows only on some screens, wake it by a media query (\`with { wake: '(max-width: …)' }\`); if it opens on a click, \`wake: 'interaction'\`.` : ""),
|
|
694
|
+
fps: eager_hidden.map((r) => r.fp)
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const lcp = page.visit?.paints?.lcp ?? page.vitals.lcp;
|
|
698
|
+
if (typeof lcp === "number") {
|
|
699
|
+
const late = rows.filter((r) => EAGER.has(r.wake) && !r.below_fold && r.done - lcp >= LATE_MS);
|
|
700
|
+
const compiling = late.filter((r) => r.done - lcp - dev_compile_ms(page, r) < LATE_MS);
|
|
701
|
+
if (late.length) findings.push({
|
|
702
|
+
code: "late-interactive",
|
|
703
|
+
severity: compiling.length === late.length ? "info" : "warn",
|
|
704
|
+
message: `${list(late.map((r) => `${r.name} (${Math.round(r.done - lcp)} ms)`))} on the first screen woke long after the page looked finished (LCP ${Math.round(lcp)} ms). A visitor can click it and get nothing in that gap.` + (compiling.length ? ` ${compiling.length === late.length ? "Most of the gap" : `For ${list(compiling.map((r) => r.name))}, most of it`} was the dev server compiling its code on this first request: reload for a warm reading.` : ""),
|
|
705
|
+
fix: "Shrink what it loads and what hydrates before it, or render it as plain HTML (a lake) if it does not need to be interactive at once.",
|
|
706
|
+
fps: late.map((r) => r.fp)
|
|
707
|
+
});
|
|
708
|
+
const lcp_fp = page.visit?.paints?.lcp_fp;
|
|
709
|
+
const hit = lcp_fp ? rows.find((r) => r.fp === lcp_fp) : void 0;
|
|
710
|
+
if (hit && (hit.recovered || hit.changed && (page.visit?.paints?.lcp_replaced || lcp >= hit.done))) findings.push({
|
|
711
|
+
code: "lcp-repaint",
|
|
712
|
+
severity: "warn",
|
|
713
|
+
message: `The largest paint (${page.visit?.paints?.lcp_tag ?? "element"}) is inside ${hit.name}, which changed its markup on hydration — the biggest thing on screen repaints as it wakes.`,
|
|
714
|
+
fix: "Make the island render the same markup on both sides (see the markup finding), or keep the hero out of the island.",
|
|
715
|
+
fps: [hit.fp]
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const lp = page.visit?.paints;
|
|
719
|
+
if (lp?.lcp_lazy) {
|
|
720
|
+
const file = lp.lcp_url ? lp.lcp_url.slice(lp.lcp_url.lastIndexOf("/") + 1).split("?")[0] : "";
|
|
721
|
+
findings.push({
|
|
722
|
+
code: "lcp-lazy",
|
|
723
|
+
severity: "warn",
|
|
724
|
+
message: `The largest paint (${lp.lcp_tag ?? "img"}${file ? ` ${file}` : ""}${lp.lcp_fp ? ` in ${by_fp.get(lp.lcp_fp)?.name ?? lp.lcp_fp.slice(0, 8)}` : ""}) carries loading="lazy": the browser waits to fetch it until layout says it is on screen, and puts it behind the page's other downloads.`,
|
|
725
|
+
fix: "Drop `loading=\"lazy\"` on images of the first screen (keep it for the ones below), and give the hero `fetchpriority=\"high\"`.",
|
|
726
|
+
fps: lp.lcp_fp ? [lp.lcp_fp] : []
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
const fcp_at = page.visit?.paints?.fcp ?? page.vitals.fcp;
|
|
730
|
+
const faces = page.visit?.font_faces;
|
|
731
|
+
if (faces?.length && typeof fcp_at === "number") {
|
|
732
|
+
const late = [];
|
|
733
|
+
for (const r of page.visit?.resources ?? []) {
|
|
734
|
+
if (r.type !== "font" || r.end - fcp_at < 100) continue;
|
|
735
|
+
const face = faces.find((f) => f.urls.includes(r.url));
|
|
736
|
+
if (!face || face.display !== "auto" && face.display !== "block") continue;
|
|
737
|
+
if (late.some((l) => l.family === face.family)) continue;
|
|
738
|
+
late.push({
|
|
739
|
+
family: face.family,
|
|
740
|
+
display: face.display,
|
|
741
|
+
after: Math.round(r.end - fcp_at),
|
|
742
|
+
file: r.url.slice(r.url.lastIndexOf("/") + 1).split("?")[0]
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
const html_end = page.visit?.nav?.res_end;
|
|
746
|
+
const held = [];
|
|
747
|
+
if (!late.length && typeof html_end === "number" && fcp_at - html_end >= 300) for (const r of page.visit?.resources ?? []) {
|
|
748
|
+
if (r.type !== "font" || r.end > fcp_at || fcp_at - r.end > 60) continue;
|
|
749
|
+
const face = faces.find((f) => f.urls.includes(r.url));
|
|
750
|
+
if (!face || face.display !== "auto" && face.display !== "block") continue;
|
|
751
|
+
if (held.some((l) => l.family === face.family)) continue;
|
|
752
|
+
held.push({
|
|
753
|
+
family: face.family,
|
|
754
|
+
display: face.display,
|
|
755
|
+
at: Math.round(r.end),
|
|
756
|
+
file: r.url.slice(r.url.lastIndexOf("/") + 1).split("?")[0]
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
if (late.length || held.length) findings.push({
|
|
760
|
+
code: "font-invisible",
|
|
761
|
+
severity: "warn",
|
|
762
|
+
message: late.length ? `Text in ${list(late.map((l) => `'${l.family}' (${l.file}, ${l.after} ms after the first paint)`))} stayed invisible until its font arrived: font-display is ${late[0].display}, so the browser hides the text, up to 3 s, rather than show a fallback.` : `Text in ${list(held.map((l) => `'${l.family}' (${l.file}, in at ${l.at} ms)`))} held the first paint: nothing showed until its font arrived (the first paint at ${Math.round(fcp_at)} ms, ${Math.round(fcp_at - html_end)} ms after the HTML). Font-display is ${held[0].display}, so the browser hides the text, up to 3 s, rather than show a fallback.`,
|
|
763
|
+
fix: "Give the @font-face `font-display: swap` (the text shows in a fallback at once, then switches) or `optional` (no switch, no shift), and preload the font file the first screen needs.",
|
|
764
|
+
fps: []
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
{
|
|
768
|
+
const fcp = page.visit?.paints?.fcp ?? page.vitals.fcp;
|
|
769
|
+
const at = page.dock_open_at;
|
|
770
|
+
if (typeof at === "number" && typeof fcp === "number" && fcp >= at && fcp - at <= 250) findings.push({
|
|
771
|
+
code: "dock-in-paint",
|
|
772
|
+
severity: "info",
|
|
773
|
+
message: `The devtools dock was open as this page loaded (from ${Math.round(at)} ms), and the first paint was recorded just after it (${Math.round(fcp)} ms): the browser may have counted the dock's own text as the page's first paint${page.browser?.startsWith("Safari") ? ` — ${page.browser} its largest paint too` : ""}, so the paint numbers here can read early.`,
|
|
774
|
+
fix: "Close the dock and reload: the page's own paints are measured without it. (Open it again after the load; it reads the same visit.)",
|
|
775
|
+
fps: []
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
const big = (page.visit?.images_oversized ?? []).map((i) => ({
|
|
779
|
+
...i,
|
|
780
|
+
waste: Math.round(i.bytes * (1 - i.shown[0] * i.shown[1] * i.dpr * i.dpr / (i.natural[0] * i.natural[1])))
|
|
781
|
+
})).sort((a, b) => b.waste - a.waste);
|
|
782
|
+
const big_waste = big.reduce((n, i) => n + i.waste, 0);
|
|
783
|
+
if (big.length && big_waste >= 5e4) {
|
|
784
|
+
const kb = (n) => `${Math.round(n / 1024)} KB`;
|
|
785
|
+
const file = (u) => u.slice(u.lastIndexOf("/") + 1).split("?")[0] || u;
|
|
786
|
+
const named = big.slice(0, 3).map((i) => `${file(i.url)} (${i.natural[0]}×${i.natural[1]}, shown at ${i.shown[0]}×${i.shown[1]}${i.dpr !== 1 ? ` on a ${i.dpr}× screen` : ""}${i.fp ? ` in ${name_of(i.fp)}` : ""})`);
|
|
787
|
+
findings.push({
|
|
788
|
+
code: "image-oversized",
|
|
789
|
+
severity: "warn",
|
|
790
|
+
message: `${list(big.length > 3 ? [...named, `${big.length - 3} more`] : named)} ${big.length === 1 ? "is" : "are"} sent far bigger than shown: about ${kb(big_waste)} of ${kb(big.reduce((n, i) => n + i.bytes, 0))} is pixels nobody sees.`,
|
|
791
|
+
fix: "Serve each image near the size it shows at: a `srcset` with a few widths and a `sizes` that says how wide it shows (the browser picks the smallest that is sharp), or resize the file itself.",
|
|
792
|
+
fps: [...new Set(big.flatMap((i) => i.fp ? [i.fp] : []))]
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
const jank = page.visit?.scroll_jank ?? [];
|
|
796
|
+
const jank_ms = jank.reduce((n, j) => n + j.ms, 0);
|
|
797
|
+
const worst = jank.reduce((m, j) => Math.max(m, j.ms), 0);
|
|
798
|
+
if (jank.length && (worst >= 100 || jank_ms >= 200)) {
|
|
799
|
+
const file = (u) => {
|
|
800
|
+
const q = u.indexOf("?");
|
|
801
|
+
const p = q === -1 ? u : u.slice(0, q);
|
|
802
|
+
return p.slice(p.lastIndexOf("/") + 1) || "an inline script";
|
|
803
|
+
};
|
|
804
|
+
const by = /* @__PURE__ */ new Map();
|
|
805
|
+
for (const j of jank) for (const s of j.scripts) {
|
|
806
|
+
const fn = s.fn.length > 2 ? s.fn : "";
|
|
807
|
+
const where = s.island ? `${s.island}'s code` : file(s.url);
|
|
808
|
+
const label = `${fn ? `${fn} (${where})` : where}${s.invoker ? `, run by ${s.invoker}` : ""}`;
|
|
809
|
+
const e = by.get(label);
|
|
810
|
+
if (e) e.ms += s.ms;
|
|
811
|
+
else by.set(label, {
|
|
812
|
+
label,
|
|
813
|
+
ms: s.ms
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
const top = [...by.values()].sort((a, b) => b.ms - a.ms)[0];
|
|
817
|
+
findings.push({
|
|
818
|
+
code: "scroll-jank",
|
|
819
|
+
severity: "warn",
|
|
820
|
+
message: `Scrolling stalled: ${jank.length} frame${jank.length === 1 ? "" : "s"} took ${jank.length === 1 ? `${Math.round(worst)} ms` : `up to ${Math.round(worst)} ms (${Math.round(jank_ms)} ms in all)`} while the page scrolled${top ? `, mostly ${top.label} (${Math.round(top.ms)} ms)` : ""}: the page could not follow the visitor's scroll.`,
|
|
821
|
+
fix: "Do little in a scroll handler: mark it passive, do the work once per frame (requestAnimationFrame), or watch with an IntersectionObserver instead of measuring on every scroll event.",
|
|
822
|
+
fps: []
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
const forced = page.visit?.forced_layout ?? [];
|
|
826
|
+
if (forced.length) {
|
|
827
|
+
const file = (u) => {
|
|
828
|
+
const q = u.indexOf("?");
|
|
829
|
+
const p = q === -1 ? u : u.slice(0, q);
|
|
830
|
+
return p.slice(p.lastIndexOf("/") + 1) || "an inline script";
|
|
831
|
+
};
|
|
832
|
+
const groups = /* @__PURE__ */ new Map();
|
|
833
|
+
for (const f of forced) {
|
|
834
|
+
let best;
|
|
835
|
+
let best_overlap = 0;
|
|
836
|
+
for (const i of page.islands) {
|
|
837
|
+
const from = i.turn ?? i.loaded;
|
|
838
|
+
const overlap = Math.min(work_end(i), f.end) - Math.max(from, f.start);
|
|
839
|
+
if (overlap > best_overlap) {
|
|
840
|
+
best_overlap = overlap;
|
|
841
|
+
best = i;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
const key = best ? best.fp : `${f.url}\0${f.fn}`;
|
|
845
|
+
const label = best ? `${name_of(best.fp, best.entry)} while it hydrated` : `${file(f.url)}${f.fn ? ` (${f.fn})` : ""}`;
|
|
846
|
+
const g = groups.get(key);
|
|
847
|
+
if (g) {
|
|
848
|
+
g.ms += f.ms;
|
|
849
|
+
g.n++;
|
|
850
|
+
} else groups.set(key, {
|
|
851
|
+
label,
|
|
852
|
+
...best ? { fp: best.fp } : {},
|
|
853
|
+
ms: f.ms,
|
|
854
|
+
n: 1
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
const top = [...groups.values()].filter((g) => g.ms >= 30).sort((a, b) => b.ms - a.ms);
|
|
858
|
+
if (top.length) findings.push({
|
|
859
|
+
code: "forced-layout",
|
|
860
|
+
severity: "warn",
|
|
861
|
+
message: `${list(top.slice(0, 3).map((g) => `${g.label} (${Math.round(g.ms)} ms${g.fp ? why_cpu(g.fp) : ""})`))} made the browser recalculate style and layout in the middle of running: the code read sizes or positions (offsetWidth, getBoundingClientRect, getComputedStyle) after changing the page, so each read laid the page out again.`,
|
|
862
|
+
fix: "Read every size first, then make every change (or put the changes in one requestAnimationFrame); never read layout between DOM writes in a loop. Sizes CSS can handle (width from the content, a fixed aspect ratio) need no read at all.",
|
|
863
|
+
fps: top.flatMap((g) => g.fp ? [g.fp] : [])
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
const below = page.visit?.images_eager_below ?? [];
|
|
867
|
+
const below_bytes = below.reduce((n, i) => n + i.bytes, 0);
|
|
868
|
+
if (below.length && below_bytes >= 1e5) {
|
|
869
|
+
const kb = (n) => `${Math.round(n / 1024)} KB`;
|
|
870
|
+
const file = (u) => u.slice(u.lastIndexOf("/") + 1).split("?")[0] || u;
|
|
871
|
+
const vh = page.visit?.viewport?.[1];
|
|
872
|
+
const by_file = /* @__PURE__ */ new Map();
|
|
873
|
+
for (const i of below) {
|
|
874
|
+
const g = by_file.get(file(i.url));
|
|
875
|
+
if (g) {
|
|
876
|
+
g.n++;
|
|
877
|
+
g.bytes += i.bytes;
|
|
878
|
+
g.top = Math.min(g.top, i.top);
|
|
879
|
+
} else by_file.set(file(i.url), {
|
|
880
|
+
n: 1,
|
|
881
|
+
bytes: i.bytes,
|
|
882
|
+
top: i.top,
|
|
883
|
+
...i.fp ? { fp: i.fp } : {}
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
const groups = [...by_file].sort((a, b) => b[1].bytes - a[1].bytes);
|
|
887
|
+
const named = groups.slice(0, 3).map(([f, g]) => `${f}${g.n > 1 ? ` ×${g.n}` : ""} (${kb(g.bytes)}, ${vh ? `${(g.top / vh).toFixed(1)} screens` : `${g.top}px`} down${g.fp ? `, in ${name_of(g.fp)}` : ""})`);
|
|
888
|
+
findings.push({
|
|
889
|
+
code: "images-eager-below",
|
|
890
|
+
severity: "warn",
|
|
891
|
+
message: `${list(groups.length > 3 ? [...named, `${groups.length - 3} more`] : named)} ${below.length === 1 ? "loads" : "load"} at start though far below the first screen: ${kb(below_bytes)} that competed with the first screen's files for the network.`,
|
|
892
|
+
fix: "Give images below the first screen `loading=\"lazy\"` (and their width and height, so nothing shifts when they arrive): the browser fetches them as the visitor scrolls near.",
|
|
893
|
+
fps: [...new Set(below.flatMap((i) => i.fp ? [i.fp] : []))]
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
const unsized = page.visit?.images_unsized ?? [];
|
|
897
|
+
if (unsized.length) {
|
|
898
|
+
const file = (u) => u.slice(u.lastIndexOf("/") + 1).split("?")[0] || u;
|
|
899
|
+
const names = [...new Set(unsized.map(file))];
|
|
900
|
+
const cls = page.vitals.cls;
|
|
901
|
+
const moved = typeof cls === "number" && cls >= .1;
|
|
902
|
+
findings.push({
|
|
903
|
+
code: "img-unsized",
|
|
904
|
+
severity: moved ? "warn" : "info",
|
|
905
|
+
message: `${names.length === 1 ? `${names[0]} has` : `${list(names.slice(0, 4))}${names.length > 4 ? ` and ${names.length - 4} more` : ""} have`} no size the browser knows before the file comes (no width and height, no CSS aspect-ratio), so what is below moves when it arrives${moved ? ` — and this page moved (CLS ${Math.round(cls * 100) / 100})` : " (unless CSS sets its height)"}.`,
|
|
906
|
+
fix: "Give each `<img>` its width and height attributes (the file’s own; CSS can still scale it), or a CSS aspect-ratio: the browser then holds the room from the first paint.",
|
|
907
|
+
fps: []
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const dom = page.visit?.dom;
|
|
911
|
+
if (dom && dom.nodes >= 1500) {
|
|
912
|
+
const top = dom.islands[0];
|
|
913
|
+
const held = top && top.nodes >= dom.nodes * .3 ? top : void 0;
|
|
914
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
915
|
+
findings.push({
|
|
916
|
+
code: "dom-large",
|
|
917
|
+
severity: "warn",
|
|
918
|
+
message: `The page has ${n(dom.nodes)} elements${dom.depth ? ` (nested ${dom.depth} deep; the most children, ${n(dom.widest.children)}, under ${dom.widest.at})` : ""}` + (held ? `, ${n(held.nodes)} of them inside ${name_of(held.fp)}: an island hydrates over every element of its own, so it pays for all of them as it wakes.` : ": every element costs memory and style and layout work on each change."),
|
|
919
|
+
fix: held ? `Render fewer at once in ${name_of(held.fp)}: page or window a long list (only what is on screen), and keep big static markup outside the island (a lake costs nothing to hydrate).` : "Render fewer at once: page or window long lists (only what is on screen), and flatten markup that nests wrappers for layout alone.",
|
|
920
|
+
fps: held ? [held.fp] : []
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const load_end = page.visit?.nav?.load ?? 0;
|
|
924
|
+
if (load_end && now - load_end >= NEVER_MS) {
|
|
925
|
+
const failed = new Set(failures.map((f) => f.fp));
|
|
926
|
+
const asleep = regions.filter((r) => r.kind === "island" && r.wake === "load" && !r.hydrated && !failed.has(r.fp));
|
|
927
|
+
if (asleep.length) findings.push({
|
|
928
|
+
code: "never-woke",
|
|
929
|
+
severity: "warn",
|
|
930
|
+
message: `${list(asleep.map((r) => r.name))} should have woken at load but ${asleep.length === 1 ? "is" : "are"} still asleep ${Math.round((now - load_end) / 1e3)} s later.`,
|
|
931
|
+
fix: "Look in the console for a module that never loaded, or an island nested in one that has not woken (it wakes with its parent).",
|
|
932
|
+
fps: asleep.map((r) => r.fp)
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
for (const v of vitals) {
|
|
936
|
+
if (v.rating === "good") continue;
|
|
937
|
+
const head = `${v.label} is ${v.key === "cls" ? v.value : `${Math.round(v.value)} ms`} (${v.rating === "poor" ? "poor" : "needs work"}; good is ${v.key === "cls" ? "≤ " + LIMITS[v.key][0] : "≤ " + LIMITS[v.key][0] + " ms"}).`;
|
|
938
|
+
const why = v.key === "inp" ? explain_interaction(page.visit?.interaction, rows, name_of, page.visit?.origin, page.interaction_cpu) : v.key === "lcp" ? explain_lcp(page, name_of) : v.key === "cls" ? explain_cls(page, rows, name_of) : v.key === "ttfb" ? explain_ttfb(page) : v.key === "fcp" ? explain_fcp(page) : null;
|
|
939
|
+
findings.push({
|
|
940
|
+
code: why ? v.key === "inp" ? "slow-interaction" : v.key === "lcp" ? "slow-lcp" : v.key === "ttfb" ? "slow-ttfb" : v.key === "fcp" ? "slow-fcp" : "shift-cause" : `vital-${v.key}`,
|
|
941
|
+
severity: why && "note" in why && why.note ? "info" : v.rating === "poor" ? "warn" : "info",
|
|
942
|
+
message: why ? `${head} ${why.message}` : head,
|
|
943
|
+
...why ? { fix: why.fix } : {},
|
|
944
|
+
fps: v.key === "lcp" && page.visit?.paints?.lcp_fp ? [page.visit.paints.lcp_fp] : why?.fps ?? [],
|
|
945
|
+
...(v.key === "ttfb" || v.key === "cls") && why && "note" in why && why.note ? { dev_compile: true } : {}
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
if (page.visit?.nav?.start_hidden) findings.push({
|
|
949
|
+
code: "first-byte-hidden",
|
|
950
|
+
severity: "info",
|
|
951
|
+
message: `The browser gave no time for this page's request and first byte (${page.browser?.startsWith("Safari") ? "Safari does" : "it does"} that for a cross-origin-isolated page, one sent with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy): the time to first byte and its parts are missing here, not zero. The rest of the visit stands.`,
|
|
952
|
+
fix: "Read the first byte from a Chromium visit, or from the server side (the profiler times the render itself).",
|
|
953
|
+
fps: []
|
|
954
|
+
});
|
|
955
|
+
const fcp = page.visit?.paints?.fcp ?? page.vitals.fcp ?? Infinity;
|
|
956
|
+
const blocking = (page.visit?.resources ?? []).filter((r) => r.blocking && r.start < fcp).map((r) => ({
|
|
957
|
+
url: r.url,
|
|
958
|
+
ms: round(r.end - r.start),
|
|
959
|
+
bytes: r.transfer ?? r.size ?? 0
|
|
960
|
+
})).sort((a, b) => b.ms - a.ms);
|
|
961
|
+
const html_at = page.visit?.nav?.res_start ?? page.visit?.nav?.res_end ?? 0;
|
|
962
|
+
let blocked_until = 0;
|
|
963
|
+
for (const r of page.visit?.resources ?? []) if (r.blocking && r.start < fcp) blocked_until = Math.max(blocked_until, r.end);
|
|
964
|
+
const held_ms = blocking.length ? Math.max(0, blocked_until - html_at) : 0;
|
|
965
|
+
const fcp_card = held_ms >= 200 ? findings.find((f) => f.code === "slow-fcp" && f.message.includes("waiting for") && f.message.includes(short(blocking[0].url))) : void 0;
|
|
966
|
+
const tune = held_ms >= 200 ? inline_threshold_tune((page.visit?.resources ?? []).filter((r) => r.blocking && r.start < fcp).map((r) => ({
|
|
967
|
+
url: r.url,
|
|
968
|
+
kind: r.type,
|
|
969
|
+
bytes: r.size ?? 0
|
|
970
|
+
}))) : null;
|
|
971
|
+
const tune_text = tune ? `With \`kit: { inlineStyleThreshold: ${tune.threshold} }\` in svelte.config.js, ${tune.files} of these stylesheets (${(tune.bytes / 1024).toFixed(1)} KB) arrive inside the HTML: ${blocking.length - tune.files} file${blocking.length - tune.files === 1 ? "" : "s"} left to wait on instead of ${blocking.length}.` : "";
|
|
972
|
+
if (fcp_card) {
|
|
973
|
+
if (held_ms >= 600) fcp_card.severity = "warn";
|
|
974
|
+
if (tune_text) fcp_card.fix = `${fcp_card.fix ?? ""} ${tune_text}`.trim();
|
|
975
|
+
} else if (held_ms >= 200) findings.push({
|
|
976
|
+
code: "render-blocking",
|
|
977
|
+
severity: held_ms >= 600 ? "warn" : "info",
|
|
978
|
+
message: `${blocking.length} file${blocking.length === 1 ? "" : "s"} blocked the first paint for ${Math.round(held_ms)} ms after the HTML arrived; the slowest took ${Math.round(blocking[0].ms)} ms (${short(blocking[0].url)}).`,
|
|
979
|
+
fix: tune_text ? `${tune_text} Merge or trim the rest, and load scripts as modules (they do not block).` : "Inline small stylesheets, merge the rest, and load scripts as modules (they do not block).",
|
|
980
|
+
fps: []
|
|
981
|
+
});
|
|
982
|
+
const bytes_by = /* @__PURE__ */ new Map();
|
|
983
|
+
if (page.visit?.resource_totals?.length) for (const t of page.visit.resource_totals) bytes_by.set(t.type, { ...t });
|
|
984
|
+
else for (const r of page.visit?.resources ?? []) {
|
|
985
|
+
const b = bytes_by.get(r.type) ?? {
|
|
986
|
+
type: r.type,
|
|
987
|
+
count: 0,
|
|
988
|
+
transfer: 0,
|
|
989
|
+
size: 0
|
|
990
|
+
};
|
|
991
|
+
b.count++;
|
|
992
|
+
b.transfer += r.transfer ?? 0;
|
|
993
|
+
b.size += r.size ?? 0;
|
|
994
|
+
bytes_by.set(r.type, b);
|
|
995
|
+
}
|
|
996
|
+
let longtask_ms = 0;
|
|
997
|
+
for (const t of page.longtasks) longtask_ms += t.ms;
|
|
998
|
+
const in_hydration = rows.reduce((s, r) => s + r.longtask_ms, 0);
|
|
999
|
+
const other = longtask_ms - in_hydration;
|
|
1000
|
+
if (other >= 150) {
|
|
1001
|
+
const biggest = page.longtasks.reduce((m, t) => t.ms > m.ms ? t : m, page.longtasks[0]);
|
|
1002
|
+
const frames = page.longtasks_from === "frames";
|
|
1003
|
+
findings.push({
|
|
1004
|
+
code: "long-tasks",
|
|
1005
|
+
severity: "info",
|
|
1006
|
+
message: frames ? `The main thread stalled ${Math.round(other)} ms outside any island's hydration (the longest ${Math.round(biggest.ms)} ms at ${Math.round(biggest.t)} ms). This browser reports no long tasks: these are the frames it drew more than 50 ms late, so each is the stall a visitor saw, not the script's exact span. Page scripts, not islands, held the main thread.` : `${Math.round(other)} ms of long tasks ran outside any island's hydration (the longest ${Math.round(biggest.ms)} ms at ${Math.round(biggest.t)} ms${why_cpu(null) || why_script()}). Page scripts, not islands, held the main thread.`,
|
|
1007
|
+
fix: "Record a Performance trace around that time to see the script; third-party tags are the usual cause.",
|
|
1008
|
+
fps: []
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
const warns = page.visit?.warnings ?? [];
|
|
1012
|
+
if (warns.length) {
|
|
1013
|
+
const by = /* @__PURE__ */ new Map();
|
|
1014
|
+
for (const w of warns) {
|
|
1015
|
+
const key = `${w.code}|${w.file ?? ""}`;
|
|
1016
|
+
const g = by.get(key) ?? {
|
|
1017
|
+
code: w.code,
|
|
1018
|
+
file: w.file,
|
|
1019
|
+
fps: /* @__PURE__ */ new Set(),
|
|
1020
|
+
n: 0
|
|
1021
|
+
};
|
|
1022
|
+
g.n++;
|
|
1023
|
+
if (w.fp) g.fps.add(w.fp);
|
|
1024
|
+
by.set(key, g);
|
|
1025
|
+
}
|
|
1026
|
+
const groups = [...by.values()];
|
|
1027
|
+
const fps = [...new Set(groups.flatMap((g) => [...g.fps]))];
|
|
1028
|
+
findings.push({
|
|
1029
|
+
code: "svelte-hydration-warning",
|
|
1030
|
+
severity: "warn",
|
|
1031
|
+
message: `Svelte warned while hydrating: ${groups.slice(0, 3).map((g) => `${g.code}${g.file ? ` in ${short(g.file)}` : ""}${g.fps.size ? ` (${list([...g.fps].map((fp) => name_of(fp)))})` : ""}${g.n > 1 ? ` ×${g.n}` : ""}`).join("; ")}. The server and the browser disagreed; Svelte kept the server's value, so the screen does not show it.`,
|
|
1032
|
+
fix: "Make the value the same on both sides (a value only the server has belongs in props; time, random and browser-only reads belong in an effect).",
|
|
1033
|
+
fps
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
const empty = [...new Set(page.empty_slots ?? [])];
|
|
1037
|
+
if (empty.length) findings.push({
|
|
1038
|
+
code: "empty-slot",
|
|
1039
|
+
severity: "warn",
|
|
1040
|
+
message: `${list(empty.map((fp) => name_of(fp)))} shows its children in a slot with nothing in it: an island's children are the server's HTML, so children the server did not render (a condition that was false there) cannot appear in the browser.`,
|
|
1041
|
+
fix: "Render the children on the server and hide them (the hidden attribute, or CSS) instead of wrapping them in {#if}; or make the part that appears later its own island inside the component.",
|
|
1042
|
+
fps: empty
|
|
1043
|
+
});
|
|
1044
|
+
const kb = (n) => `${Math.round(n / 1024)} KB`;
|
|
1045
|
+
for (const c of page.island_code ?? []) {
|
|
1046
|
+
const b = c.barrels[0];
|
|
1047
|
+
if (b) findings.push({
|
|
1048
|
+
code: "island-barrel",
|
|
1049
|
+
severity: "warn",
|
|
1050
|
+
message: `${c.name} still imports a barrel whole: ${b.file}, and the ${b.fanout} modules behind it ride into the island${c.barrels.length > 1 ? ` (and ${c.barrels.length - 1} more barrel${c.barrels.length > 2 ? "s" : ""})` : ""}. A Svelte component is never side-effect free to the bundler, so what the island does not use still ships.`,
|
|
1051
|
+
fix: `Import what the island uses from its own file, or turn on ogygia({ barrels }). If it is on, the build log names why this one was left ("barrels: skipped …").`,
|
|
1052
|
+
fps: c.fp ? [c.fp] : []
|
|
1053
|
+
});
|
|
1054
|
+
const big = c.top[0];
|
|
1055
|
+
if (big && c.bytes >= 30720 && big.bytes >= c.bytes * .4 && c.top.length > 1) findings.push({
|
|
1056
|
+
code: "island-heavy-module",
|
|
1057
|
+
severity: "info",
|
|
1058
|
+
message: `${big.file.split("/").pop()} is ${Math.round(big.bytes / c.bytes * 100)}% of ${c.name}'s code (${kb(big.bytes)} of ${kb(c.bytes)} in dev).`,
|
|
1059
|
+
fix: "That one module is where the island’s weight is: split it (one module per piece), load it when it is needed, or render it on the server.",
|
|
1060
|
+
fps: c.fp ? [c.fp] : []
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
const slow_holes = (page.hole_waits ?? []).filter((h) => !h.below_fold && h.wait_ms >= SLOW_HOLE_MS).sort((a, b) => b.wait_ms - a.wait_ms);
|
|
1064
|
+
if (slow_holes.length) {
|
|
1065
|
+
const secs = (ms) => `${(ms / 1e3).toFixed(1)} s`;
|
|
1066
|
+
const dur = (ms) => ms >= 1e3 ? secs(ms) : `${Math.round(ms)} ms`;
|
|
1067
|
+
const all = page.hole_waits ?? [];
|
|
1068
|
+
const split = (h) => {
|
|
1069
|
+
const shown = h.shown_at ?? -Infinity;
|
|
1070
|
+
const before = h.left_at !== void 0 && h.shown_at !== void 0 ? Math.max(0, h.left_at - shown) : void 0;
|
|
1071
|
+
const ttfb = h.first_at !== void 0 && h.left_at !== void 0 ? Math.max(0, h.first_at - Math.max(h.left_at, shown)) : void 0;
|
|
1072
|
+
const ahead = h.left_at === void 0 || h.batch_size ? 0 : all.filter((o) => o !== h && o.left_at !== void 0 && o.end_at !== void 0 && o.left_at < h.left_at && o.end_at >= h.left_at - 50).length;
|
|
1073
|
+
return {
|
|
1074
|
+
before,
|
|
1075
|
+
queue: h.server_queue_ms,
|
|
1076
|
+
server: h.server_ms ?? ttfb,
|
|
1077
|
+
rendered: h.server_ms !== void 0,
|
|
1078
|
+
ahead
|
|
1079
|
+
};
|
|
1080
|
+
};
|
|
1081
|
+
const parts = (h) => {
|
|
1082
|
+
const s = split(h);
|
|
1083
|
+
const out = [];
|
|
1084
|
+
if (s.before !== void 0 && s.before >= 200) out.push(`${dur(s.before)} before its request left`);
|
|
1085
|
+
if (s.queue !== void 0 && s.queue >= 200) out.push(`${dur(s.queue)} waiting for a render slot on the server`);
|
|
1086
|
+
if (s.server !== void 0 && s.server >= 50) out.push(`${dur(s.server)} ${s.rendered ? "the server render" : "waiting on the server"}`);
|
|
1087
|
+
if (h.batch_size && h.batch_size > 1) out.push(`its part of one request for ${h.batch_size} holes`);
|
|
1088
|
+
return out.length ? `: ${out.join(", ")}` : "";
|
|
1089
|
+
};
|
|
1090
|
+
const splits = slow_holes.map(split);
|
|
1091
|
+
const slotted = splits.every((s, i) => s.queue !== void 0 && s.queue >= slow_holes[i].wait_ms * .4);
|
|
1092
|
+
const late = !slotted && splits.every((s, i) => s.before !== void 0 && s.before >= slow_holes[i].wait_ms * .4);
|
|
1093
|
+
const gated = late && splits.some((s) => s.ahead >= HOLE_GATE);
|
|
1094
|
+
const known = splits.filter((s) => s.server !== void 0);
|
|
1095
|
+
const render_bound = !late && !slotted && known.length === splits.length && splits.every((s, i) => s.server >= slow_holes[i].wait_ms * .6);
|
|
1096
|
+
const elsewhere = !late && !slotted && known.length === splits.length && splits.every((s, i) => s.server < slow_holes[i].wait_ms * .4);
|
|
1097
|
+
const ahead = Math.max(...splits.map((s) => s.ahead));
|
|
1098
|
+
findings.push({
|
|
1099
|
+
code: "hole-slow",
|
|
1100
|
+
severity: slow_holes[0].wait_ms >= SLOW_HOLE_MS * 1.5 ? "warn" : "info",
|
|
1101
|
+
message: `${list(slow_holes.map((h) => `${h.name} (${secs(h.wait_ms)}${parts(h)})`))} showed ${slow_holes.length === 1 ? "its" : "their"} fallback on the first screen that long before ${slow_holes.length === 1 ? "its" : "their"} answer came.`,
|
|
1102
|
+
fix: slotted ? `It waited on the server for a render slot: a server process renders 4 holes at a time, and each holds its slot while it awaits its data, so slow holes make the next one wait (every visitor’s, on that process). Make the slow holes answer sooner (a maxAge, faster data), or put fewer of them on one page. The Server-Timing on its answer has the split.` : gated ? `It waited for its turn: the runtime runs ${HOLE_GATE} hole requests at a time, and ${ahead} were ahead of it. Put fewer holes on the first screen (render the ones that are the same for every visitor with the page, or merge small ones into one), or make the ones ahead answer sooner (a maxAge).` : late ? "Its request left late: the page was busy (long tasks) when it was due, or the hole woke late. The long tasks and the wake order on this tab show which." : render_bound ? `${splits.every((s) => s.rendered) ? "The server render" : "The server"} is the wait: give the hole a maxAge if its answer is the same for a while, start its data sooner (or in parallel), or render it with the page if it is the same for every visitor.` : elsewhere ? splits.every((s) => s.before !== void 0) ? "The server answered quickly and the request left on time: the wait came after the first byte (a large answer, its styles, or the swap) or on the network." : "The server answered quickly: the wait came before or around the request. Its request started late (the page was busy, or the hole woke late), or the network or something in front of the server held it. The report’s One clock section shows when it left." : "The hole’s server render or its data is slow: give it a maxAge if its answer is the same for a while, start its data sooner, or render it with the page if it is the same for every visitor. The profiler’s Holes section has its server time.",
|
|
1103
|
+
fps: []
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
const drift = page.fp_drift ?? [];
|
|
1107
|
+
if (drift.length) {
|
|
1108
|
+
const first = drift[0];
|
|
1109
|
+
const whose = (d) => drift.length === 1 ? "its" : `${d.name}'s`;
|
|
1110
|
+
const said = (d) => d.path ? `${whose(d)} prop \`${d.path}\` was ${d.was}, now ${d.now}` : d.was !== void 0 ? `${whose(d)} props went from …${d.was}… to …${d.now}…` : `${whose(d)} props changed`;
|
|
1111
|
+
findings.push({
|
|
1112
|
+
code: "fp-unstable",
|
|
1113
|
+
severity: "info",
|
|
1114
|
+
message: `${list(drift.map((d) => d.name))} got a new fingerprint since your last load of this page: ${said(first)}${drift.length > 1 ? ` (and ${drift.length - 1} more)` : ""}. If the page's data did not change, that value is made fresh on every render.`,
|
|
1115
|
+
fix: "The router compares fingerprints to keep a live island across a navigation, and the fingerprint is part of the page’s bytes: an island whose props change on every render is patched on every navigation, and a cache keyed on the HTML (a CDN, a post-render cache, a postrender store, an ETag) misses every time. Make the value the same for the same inputs: take it from load data, compute it in the browser (an effect), or leave it out of the props.",
|
|
1116
|
+
fps: drift.map((d) => d.fp).filter((x) => !!x)
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
for (const l of page.leftovers ?? []) {
|
|
1120
|
+
const what = [];
|
|
1121
|
+
if (l.intervals) what.push(l.intervals === 1 ? "an interval running" : `${l.intervals} intervals running`);
|
|
1122
|
+
if (l.listeners.length) what.push(l.listeners.length === 1 ? `a ${l.listeners[0]} listener attached` : `${l.listeners.length} listeners attached (${counted(l.listeners)})`);
|
|
1123
|
+
const ran = l.intervals ? ` (still running: ${l.fires} run${l.fires === 1 ? "" : "s"} so far${l.last_ago !== void 0 ? `, the last ${l.last_ago < 1e3 ? `${l.last_ago} ms` : `${(l.last_ago / 1e3).toFixed(1)} s`} ago` : ""})` : "";
|
|
1124
|
+
findings.push({
|
|
1125
|
+
code: "island-leftover",
|
|
1126
|
+
severity: "warn",
|
|
1127
|
+
message: `${l.name} left ${list(what)} after it left the page${ran}. Each visit to its page adds another, and each keeps the island's state in memory.`,
|
|
1128
|
+
fix: "Take back what the island starts when it goes: return a cleanup from the `$effect` that started it (`clearInterval(id)`, `removeEventListener` with the same function and capture), or pass an AbortSignal and abort it there. The router keeps the document across navigations, so nothing a page left behind is cleared by the next page.",
|
|
1129
|
+
fps: []
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
const bf = page.bfcache;
|
|
1133
|
+
const ns = page.server_profile?.no_store;
|
|
1134
|
+
if (bf && (bf.unload.length || bf.not_restored?.length) || ns) {
|
|
1135
|
+
const unload = bf?.unload ?? [];
|
|
1136
|
+
findings.push({
|
|
1137
|
+
code: "bfcache-blocked",
|
|
1138
|
+
severity: "warn",
|
|
1139
|
+
message: (unload.length ? `${list(unload)} ${unload.length === 1 ? "adds" : "add"} an 'unload' listener: the browser keeps no page with one in its back/forward cache, so Back and Forward load this page from the server again instead of showing it at once.` : ns ? `The page answers with Cache-Control: ${ns} (the profiler's last run of it saw so): the browser keeps no page marked no-store in its back/forward cache, so Back and Forward load it from the server again instead of showing it at once.` : "This load came from Back or Forward, and the browser did not restore the page from its back/forward cache: it loaded from the server again.") + (unload.length && ns ? ` It also answers with Cache-Control: ${ns}, which keeps it out too.` : "") + (bf?.not_restored?.length ? ` The browser's reasons for this load: ${bf.not_restored.join(", ")}.` : ""),
|
|
1140
|
+
fix: "Listen to 'pagehide' (or 'visibilitychange') instead of 'unload': it runs at the same moment and keeps the cache. A `Cache-Control: no-store` on the page keeps it out too.",
|
|
1141
|
+
fps: []
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
const blockers = page.scroll_blockers ?? [];
|
|
1145
|
+
if (blockers.length) {
|
|
1146
|
+
const said = [...new Set(blockers.map((b) => `${b.owner}'s '${b.type}' listener on ${b.on}${b.forced ? " (passive: false)" : ""}`))];
|
|
1147
|
+
findings.push({
|
|
1148
|
+
code: "scroll-blocking",
|
|
1149
|
+
severity: "warn",
|
|
1150
|
+
message: `${list(said.slice(0, 3))}${said.length > 3 ? ` and ${said.length - 3} more` : ""} ${said.length === 1 ? "holds" : "hold"} scrolling: the browser must run ${said.length === 1 ? "it" : "each"} before it moves the page (it might call preventDefault), on every wheel turn or touch move, and a busy main thread then makes scrolling stutter.`,
|
|
1151
|
+
fix: "Add `{ passive: true }` when the listener never calls preventDefault. To stop scrolling, use CSS instead (`overscroll-behavior`, `touch-action`), so the browser can scroll without asking.",
|
|
1152
|
+
fps: []
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
const late = (page.restore_events ?? []).filter((e) => e.kind === "late");
|
|
1156
|
+
if (late.length) {
|
|
1157
|
+
const islands = [...new Set(late.map((e) => e.island).filter((x) => !!x))];
|
|
1158
|
+
const names = islands.map((fp) => by_fp.get(fp)?.name ?? fp.slice(0, 8));
|
|
1159
|
+
findings.push({
|
|
1160
|
+
code: "restore-late",
|
|
1161
|
+
severity: "warn",
|
|
1162
|
+
message: `${counted(late.map((e) => `<${e.host}>`))} ${late.length === 1 ? "was" : "were"} upgraded by ${late.length === 1 ? "its" : "their"} component before ogygia restored ${late.length === 1 ? "it" : "them"}: ${late.length === 1 ? "it keeps" : "they keep"} the server render's scoped form${names.length ? `, and ${list(names)} ${names.length === 1 ? "hydrates" : "hydrate"} against that (expect a heal or a client re-render)` : ""}.`,
|
|
1163
|
+
fix: "The component’s definition ran before ogygia’s restorer, which runs at the end of the body: a blocking <script> (no type=module, no defer) in the head, or before the host, defines it. Load the component library as a module or with defer — every module and deferred script runs after the restore.",
|
|
1164
|
+
fps: islands
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
const broken = (page.restore_events ?? []).filter((e) => e.kind === "mismatch");
|
|
1168
|
+
if (broken.length) {
|
|
1169
|
+
const islands = [...new Set(broken.map((e) => e.island).filter((x) => !!x))];
|
|
1170
|
+
findings.push({
|
|
1171
|
+
code: "restore-mismatch",
|
|
1172
|
+
severity: "warn",
|
|
1173
|
+
message: `The server transform left ${counted(broken.map((e) => `<${e.host}>`))} different from what Svelte rendered, after ogygia restored it: ${clip(broken[0].diff ?? "the children differ")}.`,
|
|
1174
|
+
fix: "The transform changed Svelte-owned markup ogygia could not put back: a child it dropped, or moved out of the plan’s <slot> wrappers, or a text it rewrote with no mark. Keep Svelte’s children inside the slot wrappers as they came; the island inside will otherwise heal or render again in the browser.",
|
|
1175
|
+
fps: islands
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
for (const b of page.hole_batches ?? []) {
|
|
1179
|
+
const missed = b.sent - b.delivered;
|
|
1180
|
+
if (missed <= 0) continue;
|
|
1181
|
+
const what = b.refused === "redirected" ? `was redirected${b.final_url ? ` to ${path_of(b.final_url)}` : ""}` : b.refused === "document" ? `was answered with a page${b.final_url ? ` (${path_of(b.final_url)})` : ""}, not the holes` : b.status === 0 ? "failed (the request itself)" : b.status >= 400 ? `was answered ${b.status}` : `ended without ${missed === b.sent ? "any of them" : `${missed} of them`}`;
|
|
1182
|
+
const front = b.refused !== void 0 || b.status === 405 || b.status === 403 || b.status === 401 || b.status === 404;
|
|
1183
|
+
findings.push({
|
|
1184
|
+
code: "hole-batch-missed",
|
|
1185
|
+
severity: missed === b.sent ? "warn" : "info",
|
|
1186
|
+
message: `The one request for ${b.sent} holes (${counted(b.names)}) ${what}: ${missed === b.sent ? "every one" : `${missed}`} then fetched on its own — ${missed + 1} requests instead of 1, each starting only after the batch ended.`,
|
|
1187
|
+
fix: front ? "Something in front of ogygia.handle() took the batch: a handle (auth, locale, a method filter) or a firewall / CDN rule that allows only GET on the islands endpoint. Let POST through to it there — it is the same signed capabilities as each GET, verified the same way." : "The server could not render those holes in the batch (a render failed, or an answer was too large to box). Each hole’s own request then shows its error; the Network panel has the batch’s response.",
|
|
1188
|
+
fps: []
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
const misses = page.visit?.preload_misses ?? [];
|
|
1192
|
+
if (misses.length) {
|
|
1193
|
+
const file = (u) => {
|
|
1194
|
+
const q = u.indexOf("?");
|
|
1195
|
+
const p = q === -1 ? u : u.slice(0, q);
|
|
1196
|
+
return p.slice(p.lastIndexOf("/") + 1) || u;
|
|
1197
|
+
};
|
|
1198
|
+
const kb = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
1199
|
+
const safari = !!page.browser?.startsWith("Safari");
|
|
1200
|
+
const ours = (m) => m.type === "fetch" && m.crossorigin === "anonymous" && m.hole === true;
|
|
1201
|
+
const safari_fetch = (m) => safari && m.type === "fetch" && m.crossorigin !== null;
|
|
1202
|
+
const varies = (m) => !!m.vary && m.vary.toLowerCase().split(",").some((v) => v.trim() === "origin" || v.trim() === "*");
|
|
1203
|
+
const safari_font = (m) => safari && m.type === "font" && m.crossorigin !== null && varies(m);
|
|
1204
|
+
const why = (m) => safari_font(m) ? `its answer says \`Vary: ${m.vary}\`: ${page.browser} does not reuse a font preload that varies by Origin once the @font-face asking for it comes from a stylesheet added after the page parsed${page.dev ? " (on a dev server both are common: it answers every file with Vary: Origin and adds the page's CSS by script)" : ""}` : ours(m) ? `ogygia's own hole preload and its runtime's fetch match (crossorigin="anonymous", credentials same-origin)${safari ? `; ${page.browser} never hands a fetch preload to a later fetch() — whatever its crossorigin or Cache-Control — so it fetched the answer again` : ""}` : safari_fetch(m) ? `${page.browser} never hands a fetch preload to a later fetch(), whatever its crossorigin, credentials or Cache-Control: in Safari it is always a second download (Chromium uses it)` : m.type === "font" && m.crossorigin === null ? "a font always loads in CORS mode: its preload needs `crossorigin`" : m.type === "fetch" && m.crossorigin === null ? "a fetch preload needs `crossorigin` (`anonymous` for a same-origin `fetch()`, `use-credentials` for `credentials: \"include\"`)" : m.type === "fetch" || m.type === "font" ? `its \`crossorigin="${m.crossorigin}"\`${m.crossorigin ? "" : " (anonymous)"} does not match the request's credentials` : m.type === "script" ? "a module script is preloaded with `<link rel=\"modulepreload\">`, not `rel=\"preload\" as=\"script\"`" : "its `as` or `crossorigin` does not match how the page requests the file";
|
|
1205
|
+
const wasted = misses.reduce((a, m) => a + m.bytes, 0);
|
|
1206
|
+
findings.push({
|
|
1207
|
+
code: "preload-unused",
|
|
1208
|
+
severity: "warn",
|
|
1209
|
+
message: `${list(misses.map((m) => `${file(m.url)} (${kb(m.bytes)})`))} ${misses.length === 1 ? "was" : "were"} preloaded, then downloaded again: the browser could not use the preload${misses.length === 1 ? "" : "s"}, and the page paid ${kb(wasted)} twice.`,
|
|
1210
|
+
fix: `${[...new Set(misses.map(why))].map((w) => w[0].toUpperCase() + w.slice(1)).join(". ")}.` + (misses.every(ours) ? " Nothing in the page's markup to change: it is the hole preload ogygia writes, in a browser that never uses one." : [
|
|
1211
|
+
misses.some((m) => !ours(m) && safari_fetch(m)) ? " No attribute changes a fetch preload in Safari: to start the request early there too, begin it in an inline script and hand its promise to the code that reads it — or drop the preload where Safari is most of the traffic." : "",
|
|
1212
|
+
misses.some(safari_font) ? page.dev ? " For the fonts: a build drops the dev server's habits (its CSS linked, its files answered by the host), so check whether the host also answers fonts with Vary: Origin and whether a stylesheet naming them is added by script." : " For the fonts: answer them without `Vary: Origin` (a font is the same whoever asks; a CORS header alone does not need it), or name them in a stylesheet the HTML links." : "",
|
|
1213
|
+
misses.some((m) => !ours(m) && !safari_fetch(m) && !safari_font(m)) ? " The preload and the request must match exactly, or the browser fetches the file again." : ""
|
|
1214
|
+
].join("")),
|
|
1215
|
+
fps: []
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
const never = page.visit?.preloads_unused ?? [];
|
|
1219
|
+
if (never.length) {
|
|
1220
|
+
const file = (u) => {
|
|
1221
|
+
const q = u.indexOf("?");
|
|
1222
|
+
const p = q === -1 ? u : u.slice(0, q);
|
|
1223
|
+
return p.slice(p.lastIndexOf("/") + 1) || u;
|
|
1224
|
+
};
|
|
1225
|
+
const kb = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
1226
|
+
const WHAT = {
|
|
1227
|
+
image: "no image on the page shows it",
|
|
1228
|
+
font: "no @font-face names it",
|
|
1229
|
+
style: "no stylesheet link loads it"
|
|
1230
|
+
};
|
|
1231
|
+
findings.push({
|
|
1232
|
+
code: "preload-never-used",
|
|
1233
|
+
severity: "warn",
|
|
1234
|
+
message: `${list(never.map((p) => `${file(p.url)} (${p.as}${p.bytes ? `, ${kb(p.bytes)}` : ""}: ${WHAT[p.as] ?? "nothing uses it"})`))} ${never.length === 1 ? "was" : "were"} preloaded, but nothing on the page used ${never.length === 1 ? "it" : "them"} 3 s after load: the bytes competed with the files the first screen needed.`,
|
|
1235
|
+
fix: "Remove the preload, or point it at the file the page really uses: the same URL the `<img>` or CSS asks for, the exact file the `@font-face` names.",
|
|
1236
|
+
fps: []
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
const raw_files = page.dev ? [] : page.visit?.uncompressed ?? [];
|
|
1240
|
+
const raw_doc = !page.dev && page.visit?.nav?.raw ? page.visit.nav.size ?? 0 : 0;
|
|
1241
|
+
const raw_total = raw_files.reduce((s, f) => s + f.bytes, 0) + raw_doc;
|
|
1242
|
+
if (raw_total >= UNCOMPRESSED_FINDING) {
|
|
1243
|
+
const file = (u) => {
|
|
1244
|
+
const q = u.indexOf("?");
|
|
1245
|
+
const p = q === -1 ? u : u.slice(0, q);
|
|
1246
|
+
return p.slice(p.lastIndexOf("/") + 1) || u;
|
|
1247
|
+
};
|
|
1248
|
+
const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
|
|
1249
|
+
const names = [...raw_doc ? [`the page's HTML (${kb(raw_doc)})`] : [], ...raw_files.slice(0, 4).map((f) => `${file(f.url)} (${f.type}, ${kb(f.bytes)})`)];
|
|
1250
|
+
const more = raw_files.length - 4;
|
|
1251
|
+
findings.push({
|
|
1252
|
+
code: "uncompressed",
|
|
1253
|
+
severity: raw_total >= UNCOMPRESSED_WARN ? "warn" : "info",
|
|
1254
|
+
message: `${list(names)}${more > 0 ? ` and ${more} more` : ""} came down uncompressed: ${kb(raw_total)} over the wire where gzip or brotli usually sends a quarter to a third of that. Every visitor on a slow connection waits for the difference.`,
|
|
1255
|
+
fix: "Turn on compression where the response leaves your server: a compression middleware, the adapter's precompress option for built files, or the CDN's setting. A `Cache-Control: no-transform` on the response, or a proxy that strips `Accept-Encoding`, also keeps it off.",
|
|
1256
|
+
fps: []
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
const gone = page.visit?.entry_fallbacks ?? [];
|
|
1260
|
+
if (gone.length) {
|
|
1261
|
+
const name = (f) => {
|
|
1262
|
+
if (f.name) return f.name;
|
|
1263
|
+
const q = f.entry.indexOf("?");
|
|
1264
|
+
const p = q === -1 ? f.entry : f.entry.slice(0, q);
|
|
1265
|
+
return p.slice(p.lastIndexOf("/") + 1);
|
|
1266
|
+
};
|
|
1267
|
+
const woke = gone.filter((f) => f.recovered);
|
|
1268
|
+
const dead = gone.filter((f) => !f.recovered);
|
|
1269
|
+
const parts = [];
|
|
1270
|
+
if (woke.length) parts.push(`${list(woke.map(name))} woke on the current build's code through ${woke.length === 1 ? "its" : "their"} stable name${woke.length === 1 ? "" : "s"}: the page's HTML and ${woke.length === 1 ? "that island" : "those islands"} now come from different builds`);
|
|
1271
|
+
if (dead.length) parts.push(`${list(dead.map(name))} stayed asleep: ${dead.length === 1 ? "its" : "their"} stable name failed too`);
|
|
1272
|
+
findings.push({
|
|
1273
|
+
code: "island-file-gone",
|
|
1274
|
+
severity: dead.length ? "error" : "warn",
|
|
1275
|
+
message: `${gone.length === 1 ? "An island" : `${gone.length} islands`} could not load ${gone.length === 1 ? "its own file" : "their own files"} (${list(gone.map((f) => f.src.slice(f.src.lastIndexOf("/") + 1)))}): this page came from a cache that outlived the build that made it. ${parts.join(". ")}.`,
|
|
1276
|
+
fix: "Keep the previous build's `_app/immutable/` files as long as your HTML stays cached (a CDN, a service worker) — a cached page then runs its own build throughout — or purge the cached HTML when you deploy.",
|
|
1277
|
+
fps: []
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
const navs = page.visit?.navs ?? [];
|
|
1281
|
+
const slow_navs = navs.map((n, i) => ({
|
|
1282
|
+
n,
|
|
1283
|
+
ms: n.swapped - n.t,
|
|
1284
|
+
next: navs[i + 1]?.t ?? Infinity
|
|
1285
|
+
})).filter((x) => x.ms >= SLOW_NAV_MS).sort((a, b) => b.ms - a.ms).slice(0, 3);
|
|
1286
|
+
for (const { n, ms, next } of slow_navs) {
|
|
1287
|
+
const fetch_ms = n.fetched - n.t;
|
|
1288
|
+
const styles_ms = n.styled - n.fetched;
|
|
1289
|
+
const swap_ms = n.swapped - n.styled;
|
|
1290
|
+
const woke = page.islands.filter((i) => i.t0 >= n.t && i.t0 < next);
|
|
1291
|
+
const wake_end = woke.length ? Math.max(...woke.map(work_end)) : n.swapped;
|
|
1292
|
+
const heaviest = woke.map((i) => ({
|
|
1293
|
+
name: name_of(i.fp),
|
|
1294
|
+
ms: work_end(i) - (i.turn ?? i.loaded)
|
|
1295
|
+
})).sort((a, b) => b.ms - a.ms)[0];
|
|
1296
|
+
const kb_of = (b) => b >= 1024 ? `${Math.round(b / 1024)} KB` : `${b} B`;
|
|
1297
|
+
const known = n.server !== void 0 && n.download !== void 0;
|
|
1298
|
+
const head_start = known && n.prefetched ? Math.max(0, n.server + n.download - fetch_ms) : 0;
|
|
1299
|
+
const fetch_text = n.prefetched ? `${Math.round(fetch_ms)} ms still waiting for the page after the click (prefetched on hover${known ? ` ${Math.round(head_start)} ms earlier: the server took ${Math.round(n.server)} ms to answer` : ""})` : `${Math.round(fetch_ms)} ms fetching the page from the server${known ? ` (${Math.round(n.server)} ms waiting for its first byte, ${Math.round(n.download)} ms downloading${n.bytes ? ` ${kb_of(n.bytes)}` : ""})` : ""}`;
|
|
1300
|
+
const heavy_page = known && n.download > n.server;
|
|
1301
|
+
const parts = [
|
|
1302
|
+
{
|
|
1303
|
+
key: "fetch",
|
|
1304
|
+
ms: fetch_ms,
|
|
1305
|
+
text: fetch_text
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
key: "styles",
|
|
1309
|
+
ms: styles_ms,
|
|
1310
|
+
text: `${Math.round(styles_ms)} ms loading its stylesheets`
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
key: "swap",
|
|
1314
|
+
ms: swap_ms,
|
|
1315
|
+
text: `${Math.round(swap_ms)} ms swapping it in`
|
|
1316
|
+
}
|
|
1317
|
+
];
|
|
1318
|
+
const top = parts.reduce((a, b) => b.ms > a.ms ? b : a);
|
|
1319
|
+
const srv = n.on_server;
|
|
1320
|
+
const srv_other = srv ? Math.max(0, srv.ms - srv.cpu_ms - srv.net_ms) : 0;
|
|
1321
|
+
const srv_top = !srv ? null : srv.cpu_ms >= srv.net_ms && srv.cpu_ms >= srv_other ? "cpu" : srv.net_ms >= srv_other ? "net" : "other";
|
|
1322
|
+
const server_sentence = srv ? ` On the server that page took ${srv.ms} ms: ${srv.cpu_ms} ms running code, ${srv.net_count ? `${srv.net_ms} ms waiting on ${srv.net_count} outbound call${srv.net_count === 1 ? "" : "s"}` : "no outbound calls"}, and ${srv_other} ms waiting on something else${srv.inflight ? ` (${srv.inflight} other request${srv.inflight === 1 ? " was" : "s were"} running: its CPU is shared)` : ""}.` : "";
|
|
1323
|
+
findings.push({
|
|
1324
|
+
code: "slow-navigation",
|
|
1325
|
+
severity: ms >= SLOW_NAV_WARN_MS ? "warn" : "info",
|
|
1326
|
+
message: `The in-app navigation to ${n.to} took ${Math.round(ms)} ms before the new page showed: ${parts.map((p) => p.text).join(", ")}.` + (top.key === "fetch" && !heavy_page ? server_sentence : "") + (top.key === "fetch" && !heavy_page && !srv && page.server_profiles ? server_says(page.server_profiles[n.to.split("?")[0]]).replace("this page", "that page") : "") + (woke.length ? ` Then ${woke.length === 1 ? "its island woke" : `${woke.length} islands woke`} over ${Math.round(Math.max(0, wake_end - n.swapped))} ms${heaviest && heaviest.ms >= 20 ? ` (${heaviest.name} ${Math.round(heaviest.ms)} ms to hydrate)` : ""}.` : ""),
|
|
1327
|
+
fix: top.key === "fetch" && heavy_page ? `The page itself is the wait: ${n.bytes ? `${kb_of(n.bytes)} of HTML` : "its HTML"} took longer to download than the server took to answer. Send less (render below-the-fold parts later, trim repeated markup and inline data), and make sure it is compressed.` : top.key === "fetch" && srv_top === "cpu" ? "The server's own code is the wait: profile that page (its report names the slow load or component and the lines in it), and render less on it." : top.key === "fetch" && srv_top === "net" ? "The page's outbound calls are the wait: run them in parallel instead of one after another, cache the ones that repeat, or move the slow one out of the page's load (stream it, or a hole)." : top.key === "fetch" && srv_top === "other" ? "The server waited on something that is neither its code nor an outbound call it could see: a timer, a lock, a connection pool, a database driver that does not use fetch. Look at what that page's load awaits." : top.key === "fetch" ? "The server's answer is the wait: make that page's load faster (profile the page itself: its report names the slow load), or render it with less. The router starts the fetch at the click, so the server's time is the visitor's." : top.key === "styles" ? "Its stylesheets were not in the browser yet: the router waits for them so the page never shows unstyled. Share one stylesheet across pages, or keep each page's small, so the next page's are cached or quick." : "The swap itself is heavy: a large page body to parse and put in, or many islands to reconcile. Send less HTML (render below-the-fold parts later), or split the page.",
|
|
1328
|
+
fps: []
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
const again = page.visit?.refetched ?? [];
|
|
1332
|
+
if (again.length) {
|
|
1333
|
+
const name = (f) => {
|
|
1334
|
+
if (f.name) return f.name;
|
|
1335
|
+
if (f.runtime) return "the runtime";
|
|
1336
|
+
const q = f.url.indexOf("?");
|
|
1337
|
+
const p = q === -1 ? f.url : f.url.slice(0, q);
|
|
1338
|
+
return p.slice(p.lastIndexOf("/") + 1);
|
|
1339
|
+
};
|
|
1340
|
+
const revalidated = again.filter((f) => f.how === "revalidated").sort((a, b) => b.ms - a.ms);
|
|
1341
|
+
const downloaded = again.filter((f) => f.how === "downloaded").sort((a, b) => b.bytes - a.bytes);
|
|
1342
|
+
const parts = [];
|
|
1343
|
+
if (revalidated.length) parts.push(`${revalidated.length === 1 ? "one was" : `${revalidated.length} were`} revalidated with the server (${list(revalidated.map(name))}): a round trip each${revalidated.length === 1 ? ` (${Math.round(revalidated[0].ms)} ms)` : `, the slowest ${Math.round(revalidated[0].ms)} ms`}, for ${revalidated.length === 1 ? "a file" : "files"} that cannot change`);
|
|
1344
|
+
if (downloaded.length) parts.push(`${downloaded.length === 1 ? "one" : downloaded.length} came down again on this reload (${list(downloaded.map(name))}, ${kb(downloaded.reduce((a, f) => a + f.bytes, 0))}) though the browser had just loaded ${downloaded.length === 1 ? "it" : "them"}`);
|
|
1345
|
+
findings.push({
|
|
1346
|
+
code: "files-fetched-again",
|
|
1347
|
+
severity: "warn",
|
|
1348
|
+
message: `The browser asked the server again for ${again.length === 1 ? "a file it already had" : `${again.length} files it already had`}: ${parts.join("; ")}. Their names change with their content, so a browser can keep them for good.`,
|
|
1349
|
+
fix: "Serve `_app/immutable/` with `cache-control: public, max-age=31536000, immutable` (SvelteKit's adapters do; a proxy, a CDN rule or a custom server in front can override it). The devtools Page tab shows the header the host sends.",
|
|
1350
|
+
fps: []
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
for (const h of page.hole_failures ?? []) {
|
|
1354
|
+
const path = (u) => {
|
|
1355
|
+
if (!u) return "";
|
|
1356
|
+
try {
|
|
1357
|
+
return new URL(u, "http://x").pathname;
|
|
1358
|
+
} catch {
|
|
1359
|
+
return u;
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
if (h.reason === "forbidden") {
|
|
1363
|
+
const why = h.message ?? "no reason given";
|
|
1364
|
+
const split = why.startsWith("foreign-key");
|
|
1365
|
+
const front = why.startsWith("not ogygia");
|
|
1366
|
+
findings.push({
|
|
1367
|
+
code: "hole-failed",
|
|
1368
|
+
severity: "error",
|
|
1369
|
+
message: front ? `${h.name} never got its answer: something in front of ogygia's handle refused the request (403 without x-ogygia-deny), and the fallback stands.` : `${h.name} never got its answer: the islands endpoint refused its capability (403: ${why}), and the fallback stands.`,
|
|
1370
|
+
fix: split ? "The page and its islands endpoint came from different deployments, each signing with its own key: route the islands endpoint to the deployment that served the page, and give every deployment the same OGYGIA_SECRET at runtime." : front ? `ogygia's handle never saw this request: a proxy, CDN or firewall in front of it (bot protection, a WAF rule) refused ${path(h.endpoint)}. Let that path through there.` : why === "unknown-id" ? "The page names a hole the answering deployment does not have: the page came from a build whose holes differ (a hole component moved or was renamed, or its import options changed), kept by a cache across a deploy or served by another backend." : `Open ${h.endpoint ?? "the hole’s endpoint"} (signed for this page): its x-ogygia-deny header says why the server refused it.`,
|
|
1371
|
+
fps: h.fp ? [h.fp] : []
|
|
1372
|
+
});
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
const refused = h.reason !== "error";
|
|
1376
|
+
findings.push({
|
|
1377
|
+
code: "hole-failed",
|
|
1378
|
+
severity: "error",
|
|
1379
|
+
message: refused ? `${h.name} never got its answer: the request ${h.reason === "redirected" ? `was redirected to ${path(h.final_url)}` : `was answered with a whole page (${path(h.final_url)})`}. Something in front of ogygia's handle took it, and the fallback stands.` : `${h.name} never got its answer: the request failed ${h.attempts} time${h.attempts === 1 ? "" : "s"} (${h.message ?? "no reason given"}), and the fallback stands.`,
|
|
1380
|
+
fix: refused ? `A handle that runs before ogygia's (an auth redirect, a locale bounce, a 404 page) answered the islands endpoint (${path(h.endpoint)}). Let that path through untouched there.` : `Open ${h.endpoint ?? "the hole’s endpoint"} (signed for this page) for the server's answer: the hole's server render threw, or the network dropped the request.`,
|
|
1381
|
+
fps: h.fp ? [h.fp] : []
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
const origin = page.visit?.origin;
|
|
1385
|
+
const host_ms = new Map(Object.entries(cpu?.by_host ?? {}));
|
|
1386
|
+
for (const s of page.visit?.scripts ?? []) {
|
|
1387
|
+
let host = "";
|
|
1388
|
+
try {
|
|
1389
|
+
host = new URL(s.url).host;
|
|
1390
|
+
} catch {
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
host_ms.set(host, Math.max(host_ms.get(host) ?? 0, 0) + (cpu?.by_host?.[host] ? 0 : s.ms));
|
|
1394
|
+
}
|
|
1395
|
+
const tp = origin ? third_party(page.visit?.resources ?? [], origin, cpu?.by_host || page.visit?.scripts ? host_ms : null, page.visit?.named ? new Set(page.visit.named) : void 0, page.visit?.nav?.dom_interactive, page.visit?.in_page ? new Set(page.visit.in_page) : void 0) : null;
|
|
1396
|
+
const edited = [...page.islands.filter((i) => i.changed || i.recovered || i.healed).map((i) => ({
|
|
1397
|
+
name: name_of(i.fp, i.entry),
|
|
1398
|
+
done: i.done
|
|
1399
|
+
})), ...failures.map((f) => ({
|
|
1400
|
+
name: name_of(f.fp),
|
|
1401
|
+
done: Infinity
|
|
1402
|
+
}))];
|
|
1403
|
+
for (const f of third_party_findings(tp, edited)) findings.push({
|
|
1404
|
+
...f,
|
|
1405
|
+
fps: []
|
|
1406
|
+
});
|
|
1407
|
+
const order = {
|
|
1408
|
+
error: 0,
|
|
1409
|
+
warn: 1,
|
|
1410
|
+
info: 2
|
|
1411
|
+
};
|
|
1412
|
+
findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
1413
|
+
for (const f of findings) if (f.fps.length > 1) f.fps = [...new Set(f.fps)];
|
|
1414
|
+
return {
|
|
1415
|
+
vitals,
|
|
1416
|
+
rows,
|
|
1417
|
+
findings,
|
|
1418
|
+
blocking,
|
|
1419
|
+
bytes: [...bytes_by.values()].sort((a, b) => b.transfer + b.size - (a.transfer + a.size)),
|
|
1420
|
+
longtask_ms: round(longtask_ms),
|
|
1421
|
+
third_party: tp
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* THE LARGEST PAINT, EXPLAINED: the element, the island it is in, and its four parts — the wait for
|
|
1426
|
+
* the HTML's first byte, the delay before the browser began fetching its resource (the image found
|
|
1427
|
+
* late: in CSS, added by a script, lazy), the resource's download, and the delay after it arrived
|
|
1428
|
+
* before it painted (blocking CSS or scripts, or the element shown by a script). A text paint has
|
|
1429
|
+
* no resource: first byte, then render. The fix is for the part that cost most.
|
|
1430
|
+
*/
|
|
1431
|
+
/** THE FONT THE LARGEST PAINT WAITED FOR: a text paint (no file of its own) that came within 200 ms
|
|
1432
|
+
* of a font file landing after the first paint, whose face hides its text until then (font-display
|
|
1433
|
+
* auto/block). The page's text was there all along, invisible: the font, not the server, set the
|
|
1434
|
+
* LCP. Shared by the Page tab's split and the profiler's LCP gap. */
|
|
1435
|
+
function lcp_font(visit) {
|
|
1436
|
+
const p = visit?.paints;
|
|
1437
|
+
const faces = visit?.font_faces;
|
|
1438
|
+
if (!p || p.lcp_url || typeof p.lcp !== "number" || typeof p.fcp !== "number" || !faces?.length) return null;
|
|
1439
|
+
let best = null;
|
|
1440
|
+
for (const r of visit.resources ?? []) {
|
|
1441
|
+
const held_first = typeof visit.nav?.res_end === "number" && p.fcp - visit.nav.res_end >= 300 && r.end <= p.fcp && p.fcp - r.end <= 60;
|
|
1442
|
+
if (r.type !== "font" || r.end <= p.fcp && !held_first || r.end - p.lcp > 100 || p.lcp - r.end > 200) continue;
|
|
1443
|
+
const face = faces.find((f) => f.urls.includes(r.url));
|
|
1444
|
+
if (!face || face.display !== "auto" && face.display !== "block") continue;
|
|
1445
|
+
const end = Math.min(r.end, p.lcp);
|
|
1446
|
+
if (!best || end > best.end) best = {
|
|
1447
|
+
family: face.family,
|
|
1448
|
+
file: r.url.slice(r.url.lastIndexOf("/") + 1).split("?")[0],
|
|
1449
|
+
end
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
return best;
|
|
1453
|
+
}
|
|
1454
|
+
/** WHAT THE LARGEST PAINT'S DOWNLOAD SHARED THE NETWORK WITH: images far below the first screen,
|
|
1455
|
+
* not lazy, whose own download overlapped the largest paint's file (or, a text paint, the wait from
|
|
1456
|
+
* the first byte to the paint). Shared by the Page tab's split and the profiler's LCP gap. */
|
|
1457
|
+
function lcp_rivals(visit) {
|
|
1458
|
+
const below = visit?.images_eager_below;
|
|
1459
|
+
const p = visit?.paints;
|
|
1460
|
+
if (!visit || !below?.length || typeof p?.lcp !== "number") return null;
|
|
1461
|
+
const resources = visit.resources ?? [];
|
|
1462
|
+
const res = p.lcp_url ? resources.find((r) => r.url === p.lcp_url) : void 0;
|
|
1463
|
+
const from = res ? res.req_start ?? res.start : visit.nav?.res_start ?? visit.nav?.res_end ?? 0;
|
|
1464
|
+
const to = res ? res.end : p.lcp;
|
|
1465
|
+
let bytes = 0;
|
|
1466
|
+
const files = [];
|
|
1467
|
+
for (const i of below) {
|
|
1468
|
+
const r = resources.find((x) => x.url === i.url);
|
|
1469
|
+
if (!r || r.start >= to || r.end <= from) continue;
|
|
1470
|
+
bytes += i.bytes;
|
|
1471
|
+
const f = i.url.slice(i.url.lastIndexOf("/") + 1).split("?")[0];
|
|
1472
|
+
if (!files.includes(f)) files.push(f);
|
|
1473
|
+
}
|
|
1474
|
+
return bytes ? {
|
|
1475
|
+
bytes,
|
|
1476
|
+
files
|
|
1477
|
+
} : null;
|
|
1478
|
+
}
|
|
1479
|
+
/** elements whose largest paint is their own file; any other with a URL painted a CSS background */
|
|
1480
|
+
const LCP_OWN_FILE = /* @__PURE__ */ new Set([
|
|
1481
|
+
"img",
|
|
1482
|
+
"image",
|
|
1483
|
+
"video",
|
|
1484
|
+
"svg",
|
|
1485
|
+
"input"
|
|
1486
|
+
]);
|
|
1487
|
+
/**
|
|
1488
|
+
* WHY THE BROWSER FOUND THE LARGEST PAINT'S FILE LATE, from what the visit saw: a CSS background
|
|
1489
|
+
* (found only once its stylesheet is in and the element laid out), an image its island's code added
|
|
1490
|
+
* as it woke (the request began after it hydrated: the server HTML had none), or an `<img>` in the
|
|
1491
|
+
* HTML at the low priority an image starts at. The generic advice when none of them is it.
|
|
1492
|
+
*/
|
|
1493
|
+
function late_found(p, res, islands, name_of) {
|
|
1494
|
+
const tag = p?.lcp_tag ?? "";
|
|
1495
|
+
if (p?.lcp_lazy) return "It carries `loading=\"lazy\"`: the browser held its fetch until layout said it was on screen. Drop it from the first screen's images, and give this one `fetchpriority=\"high\"`.";
|
|
1496
|
+
if (p?.lcp_url && tag && !LCP_OWN_FILE.has(tag)) return `It is a CSS background image (on the ${tag}): the browser finds it only once the stylesheet has arrived and the element is laid out. Put it in the server HTML as an \`<img>\` with \`fetchpriority="high"\`, or preload it (\`<link rel="preload" as="image" fetchpriority="high">\`).`;
|
|
1497
|
+
const asked = res ? res.req_start ?? res.start : void 0;
|
|
1498
|
+
const island = p?.lcp_fp ? islands.find((i) => i.fp === p.lcp_fp) : void 0;
|
|
1499
|
+
if (island && asked !== void 0 && asked >= island.done - 1) return `Its request began at ${Math.round(asked)} ms, after ${name_of(p?.lcp_fp)} woke (hydrated at ${Math.round(island.done)} ms): the server HTML did not carry it, the island's code added it. Render the \`<img>\` (the same src) in the island's server markup, so the browser finds it in the HTML.`;
|
|
1500
|
+
if (tag === "img" && p?.lcp_priority !== "high") return "It is an `<img>` the browser could find in the HTML, but an image starts at low priority, behind the page's scripts and stylesheets: give it `fetchpriority=\"high\"` (and never `loading=\"lazy\"` on the first screen).";
|
|
1501
|
+
if (tag === "img" && p?.held?.outcome === "cap") return `It asks for high priority, and ogygia held its own downloads back for it — but it had not arrived after ${p.held.waited_ms} ms, so they started anyway. The file itself is slow to arrive: serve it smaller (a resized, modern-format image) or from a faster origin.`;
|
|
1502
|
+
if (tag === "img") return "It already asks for high priority: something before it held the browser back (a blocking script in the `<head>`, a long chain of stylesheets). Preload it in the `<head>` (`<link rel=\"preload\" as=\"image\" fetchpriority=\"high\">`), ahead of the rest.";
|
|
1503
|
+
return "The browser found it late: put it in the HTML as an `<img>` (not a CSS background or a script-added one), never `loading=\"lazy\"` on the first screen, and preload it with `fetchpriority=\"high\"`.";
|
|
1504
|
+
}
|
|
1505
|
+
function explain_lcp(page, name_of) {
|
|
1506
|
+
const p = page.visit?.paints;
|
|
1507
|
+
const lcp = p?.lcp ?? page.vitals.lcp;
|
|
1508
|
+
const ttfb = page.visit?.nav?.res_start;
|
|
1509
|
+
if (typeof lcp !== "number" || typeof ttfb !== "number" || !(lcp > 0)) return null;
|
|
1510
|
+
const file = (u) => {
|
|
1511
|
+
const q = u.indexOf("?");
|
|
1512
|
+
const s = q === -1 ? u : u.slice(0, q);
|
|
1513
|
+
return s.slice(s.lastIndexOf("/") + 1) || u;
|
|
1514
|
+
};
|
|
1515
|
+
const res = p?.lcp_url ? (page.visit?.resources ?? []).find((r) => r.url === p.lcp_url) : void 0;
|
|
1516
|
+
const what = `${p?.lcp_tag ? `the ${p.lcp_tag}` : "an element"}${p?.lcp_url ? ` (${file(p.lcp_url)})` : ""}${p?.lcp_fp ? ` in ${name_of(p.lcp_fp)}` : ""}`;
|
|
1517
|
+
const ms = (n) => `${Math.round(Math.max(0, n))} ms`;
|
|
1518
|
+
let parts;
|
|
1519
|
+
const font = res ? null : lcp_font(page.visit);
|
|
1520
|
+
if (font) parts = [
|
|
1521
|
+
{
|
|
1522
|
+
key: "ttfb",
|
|
1523
|
+
ms: ttfb,
|
|
1524
|
+
text: `${ms(ttfb)} until the HTML's first byte`
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
key: "font",
|
|
1528
|
+
ms: font.end - ttfb,
|
|
1529
|
+
text: `${ms(font.end - ttfb)} waiting for its font '${font.family}' (${font.file}), the text invisible until it came`
|
|
1530
|
+
},
|
|
1531
|
+
{
|
|
1532
|
+
key: "render",
|
|
1533
|
+
ms: lcp - font.end,
|
|
1534
|
+
text: `${ms(lcp - font.end)} more before it painted`
|
|
1535
|
+
}
|
|
1536
|
+
];
|
|
1537
|
+
else if (res) {
|
|
1538
|
+
const asked = res.req_start ?? res.start;
|
|
1539
|
+
parts = [
|
|
1540
|
+
{
|
|
1541
|
+
key: "ttfb",
|
|
1542
|
+
ms: ttfb,
|
|
1543
|
+
text: `${ms(ttfb)} until the HTML's first byte`
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
key: "delay",
|
|
1547
|
+
ms: asked - ttfb,
|
|
1548
|
+
text: `${ms(asked - ttfb)} before the browser began fetching it`
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
key: "load",
|
|
1552
|
+
ms: res.end - asked,
|
|
1553
|
+
text: `${ms(res.end - asked)} downloading it`
|
|
1554
|
+
},
|
|
1555
|
+
{
|
|
1556
|
+
key: "render",
|
|
1557
|
+
ms: lcp - res.end,
|
|
1558
|
+
text: `${ms(lcp - res.end)} more before it painted`
|
|
1559
|
+
}
|
|
1560
|
+
];
|
|
1561
|
+
} else parts = [{
|
|
1562
|
+
key: "ttfb",
|
|
1563
|
+
ms: ttfb,
|
|
1564
|
+
text: `${ms(ttfb)} until the HTML's first byte`
|
|
1565
|
+
}, {
|
|
1566
|
+
key: "render",
|
|
1567
|
+
ms: lcp - ttfb,
|
|
1568
|
+
text: `${ms(lcp - ttfb)} more before it painted`
|
|
1569
|
+
}];
|
|
1570
|
+
const top = parts.reduce((a, b) => b.ms > a.ms ? b : a);
|
|
1571
|
+
const fix = top.key === "ttfb" ? "The server's first byte is most of it: make the page's own render faster (the profiler's report of this page names the slow load), or cache it." : top.key === "font" ? "The text was there, hidden by its font: give the @font-face `font-display: swap` (or `optional`) and preload the font file, so the text paints at once." : top.key === "delay" ? late_found(p, res, page.islands, name_of) : top.key === "load" ? "The file itself is slow to download: make it smaller (a modern format, sized to how it is shown, `srcset`), and serve it from close by." : "It was ready but did not paint: render-blocking stylesheets or scripts held the first paint, or a script (an island waking) shows the element. Render it with the server HTML, and inline or trim what blocks.";
|
|
1572
|
+
const rivals = top.key === "load" || top.key === "delay" || top.key === "font" ? lcp_rivals(page.visit) : null;
|
|
1573
|
+
const kb = (n) => `${Math.round(n / 1024)} KB`;
|
|
1574
|
+
return {
|
|
1575
|
+
message: `The largest paint was ${what}: ${parts.map((x) => x.text).join(", ")}.${rivals ? ` Beside it, ${kb(rivals.bytes)} of images far below the first screen downloaded (${rivals.files.join(", ")}).` : ""}`,
|
|
1576
|
+
fix: rivals ? `${fix} And give the images below the first screen \`loading="lazy"\`: they took a share of the network the largest paint needed.` : fix,
|
|
1577
|
+
fps: p?.lcp_fp ? [p.lcp_fp] : []
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
/** a vital moved when it changed by at least this much AND a fifth of the old value (CLS: this much) */
|
|
1581
|
+
const VITAL_FLOOR = {
|
|
1582
|
+
ttfb: 50,
|
|
1583
|
+
fcp: 100,
|
|
1584
|
+
lcp: 100,
|
|
1585
|
+
inp: 40,
|
|
1586
|
+
cls: .05
|
|
1587
|
+
};
|
|
1588
|
+
/** the part named only when it moved at least this much */
|
|
1589
|
+
const PART_FLOOR_MS = 50;
|
|
1590
|
+
/**
|
|
1591
|
+
* WHICH VITALS MOVED between two visits of a page, past the noise, and for a split vital the part
|
|
1592
|
+
* that moved most. ONE rule for both tools: the profiler's "since your last profile" and the Page
|
|
1593
|
+
* tab's "since your last load" read the same moves. `parts(side, key)` gives a side's parts.
|
|
1594
|
+
*/
|
|
1595
|
+
function vitals_moved(a, b, parts) {
|
|
1596
|
+
const out = [];
|
|
1597
|
+
for (const key of [
|
|
1598
|
+
"ttfb",
|
|
1599
|
+
"fcp",
|
|
1600
|
+
"lcp",
|
|
1601
|
+
"cls",
|
|
1602
|
+
"inp"
|
|
1603
|
+
]) {
|
|
1604
|
+
const x = a[key];
|
|
1605
|
+
const y = b[key];
|
|
1606
|
+
if (typeof x !== "number" || typeof y !== "number") continue;
|
|
1607
|
+
const d = Math.abs(y - x);
|
|
1608
|
+
if (!(key === "cls" ? d >= VITAL_FLOOR.cls : d >= Math.max(VITAL_FLOOR[key], x * .2))) continue;
|
|
1609
|
+
const m = {
|
|
1610
|
+
key,
|
|
1611
|
+
a: x,
|
|
1612
|
+
b: y
|
|
1613
|
+
};
|
|
1614
|
+
if (key !== "cls") {
|
|
1615
|
+
const pa = parts("a", key);
|
|
1616
|
+
const pb = parts("b", key);
|
|
1617
|
+
if (pa && pb) {
|
|
1618
|
+
let best;
|
|
1619
|
+
for (const q of pb) {
|
|
1620
|
+
const was = pa.find((p) => p.key === q.key);
|
|
1621
|
+
if (!was) continue;
|
|
1622
|
+
if (!best || Math.abs(q.ms - was.ms) > Math.abs(best.b - best.a)) best = {
|
|
1623
|
+
label: q.label,
|
|
1624
|
+
a: Math.round(was.ms),
|
|
1625
|
+
b: Math.round(q.ms)
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
if (best && Math.abs(best.b - best.a) >= PART_FLOOR_MS) m.part = best;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
out.push(m);
|
|
1632
|
+
}
|
|
1633
|
+
return out;
|
|
1634
|
+
}
|
|
1635
|
+
function vital_parts(page, key) {
|
|
1636
|
+
if (key === "inp") {
|
|
1637
|
+
const i = page.visit?.interaction;
|
|
1638
|
+
if (!i || !(i.delay + i.processing + i.presentation > 0)) return null;
|
|
1639
|
+
return [
|
|
1640
|
+
{
|
|
1641
|
+
key: "delay",
|
|
1642
|
+
label: "the wait before its handlers",
|
|
1643
|
+
ms: i.delay
|
|
1644
|
+
},
|
|
1645
|
+
{
|
|
1646
|
+
key: "handler",
|
|
1647
|
+
label: "its handlers",
|
|
1648
|
+
ms: i.processing
|
|
1649
|
+
},
|
|
1650
|
+
{
|
|
1651
|
+
key: "paint",
|
|
1652
|
+
label: "painting the next frame",
|
|
1653
|
+
ms: i.presentation
|
|
1654
|
+
}
|
|
1655
|
+
];
|
|
1656
|
+
}
|
|
1657
|
+
const nav = page.visit?.nav;
|
|
1658
|
+
if (key === "ttfb") {
|
|
1659
|
+
const p = nav?.phases;
|
|
1660
|
+
if (!p) return null;
|
|
1661
|
+
return [
|
|
1662
|
+
{
|
|
1663
|
+
key: "redirect",
|
|
1664
|
+
label: "redirects",
|
|
1665
|
+
ms: p.redirect ?? 0
|
|
1666
|
+
},
|
|
1667
|
+
{
|
|
1668
|
+
key: "worker",
|
|
1669
|
+
label: "the service worker",
|
|
1670
|
+
ms: p.worker ?? 0
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
key: "dns",
|
|
1674
|
+
label: "the address lookup",
|
|
1675
|
+
ms: p.dns ?? 0
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
key: "connect",
|
|
1679
|
+
label: "connecting",
|
|
1680
|
+
ms: (p.connect ?? 0) + (p.tls ?? 0)
|
|
1681
|
+
},
|
|
1682
|
+
{
|
|
1683
|
+
key: "wait",
|
|
1684
|
+
label: "the server's answer",
|
|
1685
|
+
ms: p.wait ?? 0
|
|
1686
|
+
}
|
|
1687
|
+
];
|
|
1688
|
+
}
|
|
1689
|
+
const first = nav?.res_start;
|
|
1690
|
+
if (typeof first !== "number") return null;
|
|
1691
|
+
if (key === "fcp") {
|
|
1692
|
+
const fcp = page.visit?.paints?.fcp ?? page.vitals.fcp;
|
|
1693
|
+
if (typeof fcp !== "number") return null;
|
|
1694
|
+
const html_end = Math.max(first, nav?.res_end ?? first);
|
|
1695
|
+
const blocked_until = (page.visit?.resources ?? []).filter((r) => r.blocking && r.start < fcp).reduce((m, r) => Math.max(m, r.end), html_end);
|
|
1696
|
+
return [
|
|
1697
|
+
{
|
|
1698
|
+
key: "ttfb",
|
|
1699
|
+
label: "the first byte",
|
|
1700
|
+
ms: first
|
|
1701
|
+
},
|
|
1702
|
+
{
|
|
1703
|
+
key: "html",
|
|
1704
|
+
label: "the HTML download",
|
|
1705
|
+
ms: html_end - first
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
key: "blocking",
|
|
1709
|
+
label: "the files that block the paint",
|
|
1710
|
+
ms: blocked_until - html_end
|
|
1711
|
+
},
|
|
1712
|
+
{
|
|
1713
|
+
key: "render",
|
|
1714
|
+
label: "the rest before the paint",
|
|
1715
|
+
ms: fcp - blocked_until
|
|
1716
|
+
}
|
|
1717
|
+
];
|
|
1718
|
+
}
|
|
1719
|
+
const p = page.visit?.paints;
|
|
1720
|
+
const lcp = p?.lcp ?? page.vitals.lcp;
|
|
1721
|
+
if (typeof lcp !== "number") return null;
|
|
1722
|
+
const res = p?.lcp_url ? (page.visit?.resources ?? []).find((r) => r.url === p.lcp_url) : void 0;
|
|
1723
|
+
const font = res ? null : lcp_font(page.visit);
|
|
1724
|
+
if (font) return [
|
|
1725
|
+
{
|
|
1726
|
+
key: "ttfb",
|
|
1727
|
+
label: "the first byte",
|
|
1728
|
+
ms: first
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
key: "font",
|
|
1732
|
+
label: `its font '${font.family}'`,
|
|
1733
|
+
ms: font.end - first
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
key: "render",
|
|
1737
|
+
label: "the render",
|
|
1738
|
+
ms: lcp - font.end
|
|
1739
|
+
}
|
|
1740
|
+
];
|
|
1741
|
+
if (!res) return [{
|
|
1742
|
+
key: "ttfb",
|
|
1743
|
+
label: "the first byte",
|
|
1744
|
+
ms: first
|
|
1745
|
+
}, {
|
|
1746
|
+
key: "render",
|
|
1747
|
+
label: "the render",
|
|
1748
|
+
ms: lcp - first
|
|
1749
|
+
}];
|
|
1750
|
+
const asked = res.req_start ?? res.start;
|
|
1751
|
+
return [
|
|
1752
|
+
{
|
|
1753
|
+
key: "ttfb",
|
|
1754
|
+
label: "the first byte",
|
|
1755
|
+
ms: first
|
|
1756
|
+
},
|
|
1757
|
+
{
|
|
1758
|
+
key: "delay",
|
|
1759
|
+
label: "finding the image",
|
|
1760
|
+
ms: asked - first
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
key: "load",
|
|
1764
|
+
label: "its download",
|
|
1765
|
+
ms: res.end - asked
|
|
1766
|
+
},
|
|
1767
|
+
{
|
|
1768
|
+
key: "render",
|
|
1769
|
+
label: "painting it",
|
|
1770
|
+
ms: lcp - res.end
|
|
1771
|
+
}
|
|
1772
|
+
];
|
|
1773
|
+
}
|
|
1774
|
+
/** the document stayed open at least this long after its first byte (the browser side), or its tail
|
|
1775
|
+
* came this long after the rest (the server side) */
|
|
1776
|
+
const HELD_OPEN_MS = 250;
|
|
1777
|
+
/**
|
|
1778
|
+
* THE DOCUMENT HELD OPEN: the page painted early, but its HTML kept coming — and islands wake only
|
|
1779
|
+
* once the whole document is in (DOMContentLoaded, the wake gate), so every island waited for its
|
|
1780
|
+
* end, even one that reads nothing late. Usually a load's streamed promise: the response stays open
|
|
1781
|
+
* until the last one settles. From the browser (the navigation's first and last byte, the paint,
|
|
1782
|
+
* the islands' hydrate steps after the end) and, when known, what held it (`held_open`: each streamed
|
|
1783
|
+
* promise by its `page.data` key and when it settled). With no visit, the profiled render alone
|
|
1784
|
+
* says it (its chunks: the early part, then the tail after the longest pause).
|
|
1785
|
+
*/
|
|
1786
|
+
function explain_held_open(page, rows, name_of) {
|
|
1787
|
+
const ms = (n) => `${Math.round(n)} ms`;
|
|
1788
|
+
const kb = (n) => n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
|
|
1789
|
+
const held = page.held_open;
|
|
1790
|
+
const last_at = held?.keys.length ? Math.max(...held.keys.map((k) => k.at)) : void 0;
|
|
1791
|
+
const holders = held && last_at !== void 0 ? held.keys.filter((k) => k.at >= last_at - 20) : [];
|
|
1792
|
+
const holder_text = holders.length ? holders.map((k) => k.key ? `\`page.data.${k.key}\`` : "a streamed promise").filter((x, i, a) => a.indexOf(x) === i).join(", ") : "";
|
|
1793
|
+
const fix_streamed = "A promise the load returns without awaiting streams, and the document stays open until it settles, so every island waits for it. If the islands need the value anyway, await it in the load: the page ships with it, and nothing waits after. If only one part of the page needs it, make that part a server island (`render: 'deferred'`): it loads after the page, and the page's islands wake without it.";
|
|
1794
|
+
const nav = page.visit?.nav;
|
|
1795
|
+
const fcp = page.visit?.paints?.fcp ?? page.vitals.fcp;
|
|
1796
|
+
if (nav && typeof nav.res_start === "number" && typeof nav.res_end === "number") {
|
|
1797
|
+
const open = nav.res_end - nav.res_start;
|
|
1798
|
+
if (open < HELD_OPEN_MS || typeof fcp !== "number" || fcp > nav.res_end - 150) return null;
|
|
1799
|
+
const end = nav.res_end;
|
|
1800
|
+
const waited = rows.filter((r) => r.done - r.hydrate_ms >= end - 5);
|
|
1801
|
+
if (!waited.length) return null;
|
|
1802
|
+
const first = Math.min(...waited.map((r) => r.done - r.hydrate_ms));
|
|
1803
|
+
const names = waited.slice(0, 4).map((r) => name_of(r.fp) || r.name);
|
|
1804
|
+
const more = waited.length > names.length ? ` and ${waited.length - names.length} more` : "";
|
|
1805
|
+
const why = holder_text ? held.side === "server" ? `; on the server the last ${kb(held.late_bytes ?? 0)} left ${ms((held.late_ms ?? 0) - (held.early_ms ?? 0))} after the rest, held by ${holder_text}` : `; it was held by ${holder_text}, settled at ${ms(last_at)}` : "";
|
|
1806
|
+
const big = !holder_text && (nav.size ?? 0) >= 3e5;
|
|
1807
|
+
return {
|
|
1808
|
+
code: "html-held-open",
|
|
1809
|
+
severity: end - fcp >= 300 ? "warn" : "info",
|
|
1810
|
+
message: `The page painted at ${ms(fcp)}, but its HTML kept coming until ${ms(end)} (${ms(open)} after its first byte)${why}. Islands wake only once the whole document is in (DOMContentLoaded${nav.dcl ? ` at ${ms(nav.dcl)}` : ""}), so ${names.join(", ")}${more} waited: the first hydrated at ${ms(first)}, ${ms(first - fcp)} after the paint.`,
|
|
1811
|
+
fix: holder_text ? fix_streamed : big ? `The HTML itself is large (${kb(nav.size)}): its download is the wait. Ship less markup: page long lists, move what is below the fold into a server island (\`render: 'deferred'\`), and keep big data out of the page seed.` : `Something kept the response open after its first part: usually a promise a load returns without awaiting (it streams, and the document ends only when it settles), or a proxy that buffers. The profiler's report of this page names what the late bytes were. ${fix_streamed}`,
|
|
1812
|
+
fps: waited.map((r) => r.fp)
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
if (!held || held.side !== "server" || !holder_text || !held.islands) return null;
|
|
1816
|
+
const pause = (held.late_ms ?? 0) - (held.early_ms ?? 0);
|
|
1817
|
+
if (pause < HELD_OPEN_MS) return null;
|
|
1818
|
+
return {
|
|
1819
|
+
code: "html-held-open",
|
|
1820
|
+
severity: "warn",
|
|
1821
|
+
message: `The document streamed: its first ${kb(held.early_bytes ?? 0)} left the server ${ms(held.early_ms ?? 0)} into the render, but it stayed open until ${ms(held.late_ms ?? 0)}, held by ${holder_text}. In the browser, islands wake only once the whole document is in (DOMContentLoaded), so the page's ${held.islands} island${held.islands === 1 ? "" : "s"} wait${held.islands === 1 ? "s" : ""} ${ms(pause)} longer for it.`,
|
|
1822
|
+
fix: fix_streamed,
|
|
1823
|
+
fps: []
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* THE FIRST PAINT, EXPLAINED: its four parts — the wait for the HTML's first byte, the HTML's own
|
|
1828
|
+
* download, the files that blocked the paint after it (stylesheets, classic scripts in the head:
|
|
1829
|
+
* the slowest named), and the rest before the paint (the main thread busy: long tasks before it).
|
|
1830
|
+
* The fix is the costliest part's.
|
|
1831
|
+
*/
|
|
1832
|
+
function explain_fcp(page) {
|
|
1833
|
+
const nav = page.visit?.nav;
|
|
1834
|
+
const fcp = page.visit?.paints?.fcp ?? page.vitals.fcp;
|
|
1835
|
+
if (typeof fcp !== "number" || typeof nav?.res_start !== "number") return null;
|
|
1836
|
+
const first = nav.res_start;
|
|
1837
|
+
const html_end = Math.max(first, nav.res_end ?? first);
|
|
1838
|
+
const blockers = (page.visit?.resources ?? []).filter((r) => r.blocking && r.start < fcp);
|
|
1839
|
+
const blocked_until = blockers.reduce((m, r) => Math.max(m, r.end), html_end);
|
|
1840
|
+
const slowest = blockers.reduce((a, r) => !a || r.end - r.start > a.end - a.start ? r : a, void 0);
|
|
1841
|
+
const busy = page.longtasks.filter((t) => t.t < fcp && t.t + t.ms > blocked_until).reduce((a, t) => a + Math.min(t.t + t.ms, fcp) - Math.max(t.t, blocked_until), 0);
|
|
1842
|
+
const ms = (n) => `${Math.round(Math.max(0, n))} ms`;
|
|
1843
|
+
const file = (u) => {
|
|
1844
|
+
const q = u.indexOf("?");
|
|
1845
|
+
const s = q === -1 ? u : u.slice(0, q);
|
|
1846
|
+
return s.slice(s.lastIndexOf("/") + 1) || u;
|
|
1847
|
+
};
|
|
1848
|
+
const parts = [
|
|
1849
|
+
{
|
|
1850
|
+
key: "ttfb",
|
|
1851
|
+
ms: first,
|
|
1852
|
+
text: `${ms(first)} until the HTML's first byte`
|
|
1853
|
+
},
|
|
1854
|
+
{
|
|
1855
|
+
key: "html",
|
|
1856
|
+
ms: html_end - first,
|
|
1857
|
+
text: `${ms(html_end - first)} downloading the HTML`
|
|
1858
|
+
},
|
|
1859
|
+
{
|
|
1860
|
+
key: "blocking",
|
|
1861
|
+
ms: blocked_until - html_end,
|
|
1862
|
+
text: `${ms(blocked_until - html_end)} waiting for ${blockers.length === 1 ? "a file that blocks" : `${blockers.length} files that block`} the paint${slowest ? ` (the slowest ${file(slowest.url)}, ${ms(slowest.end - slowest.start)})` : ""}`
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
key: "render",
|
|
1866
|
+
ms: fcp - blocked_until,
|
|
1867
|
+
text: `${ms(fcp - blocked_until)} more before it painted${busy >= 50 ? ` (${ms(busy)} of it long tasks)` : ""}`
|
|
1868
|
+
}
|
|
1869
|
+
].filter((p) => p.ms >= 1 || p.key === "ttfb");
|
|
1870
|
+
const top = parts.reduce((a, b) => b.ms > a.ms ? b : a);
|
|
1871
|
+
const fix = top.key === "ttfb" ? "The server's first byte is most of it: see the first-byte finding (and the profiler's report of this page) for where the server's time went." : top.key === "html" ? "The HTML itself is slow to arrive: send less of it (render below-the-fold parts later, trim inline data), and make sure it is compressed." : top.key === "blocking" ? `The paint waits for ${slowest ? file(slowest.url) : "blocking files"}: inline the few rules the first screen needs and load the rest without blocking, and give scripts in the head \`defer\` or \`type="module"\`.` : "Everything had arrived, but the main thread was busy: a script ran before the first paint. Move it after (defer it, or run it on idle).";
|
|
1872
|
+
return {
|
|
1873
|
+
message: `The first paint came after ${parts.map((p) => p.text).join(", ")}.`,
|
|
1874
|
+
fix,
|
|
1875
|
+
fps: []
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* THE WAIT FOR THE FIRST BYTE, EXPLAINED: the steps the browser timed before it (a redirect, a
|
|
1880
|
+
* service worker starting, the DNS lookup, the connection and its TLS, the request until the
|
|
1881
|
+
* server answered), and — when the document carries it — what the server's own Server-Timing says
|
|
1882
|
+
* its wait went to. The fix is the costliest step's.
|
|
1883
|
+
*/
|
|
1884
|
+
/** What the profiler's last run of the page says the server did (or how to find out). */
|
|
1885
|
+
function server_says(brief) {
|
|
1886
|
+
if (!brief) return " Profile this page (the Profiler tab) to see where the server’s time went.";
|
|
1887
|
+
const ago = brief.ago_min < 1 ? "just now" : `${Math.round(brief.ago_min)} min ago`;
|
|
1888
|
+
if (brief.prerendered) return ` The profiler's last run of this page (${ago}) found it prerendered${brief.prerendered === "file" ? ": the server answered with the file the build wrote, no render ran" : " (its route exports prerender)"} — its first byte is a file being served, so a slow one is the host or the network, not a render to profile.`;
|
|
1889
|
+
return ` The profiler's last run of this page (${ago}): the server render took ${Math.round(brief.render_ms)} ms${brief.calls ? `, ${brief.calls} outbound call${brief.calls === 1 ? "" : "s"} (${Math.round(brief.calls_ms)} ms)` : ", no outbound calls"}${brief.top ? `; it says: ${brief.top}` : ""}.`;
|
|
1890
|
+
}
|
|
1891
|
+
function explain_ttfb(page) {
|
|
1892
|
+
const p = page.visit?.nav?.phases;
|
|
1893
|
+
if (!p) return null;
|
|
1894
|
+
const ms = (n) => `${Math.round(n)} ms`;
|
|
1895
|
+
const steps = [
|
|
1896
|
+
{
|
|
1897
|
+
key: "redirect",
|
|
1898
|
+
ms: p.redirect ?? 0,
|
|
1899
|
+
text: `${ms(p.redirect ?? 0)} in redirects`
|
|
1900
|
+
},
|
|
1901
|
+
{
|
|
1902
|
+
key: "worker",
|
|
1903
|
+
ms: p.worker ?? 0,
|
|
1904
|
+
text: `${ms(p.worker ?? 0)} starting a service worker`
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
key: "dns",
|
|
1908
|
+
ms: p.dns ?? 0,
|
|
1909
|
+
text: `${ms(p.dns ?? 0)} looking up the address`
|
|
1910
|
+
},
|
|
1911
|
+
{
|
|
1912
|
+
key: "connect",
|
|
1913
|
+
ms: (p.connect ?? 0) + (p.tls ?? 0),
|
|
1914
|
+
text: `${ms((p.connect ?? 0) + (p.tls ?? 0))} connecting${p.tls ? ` (${ms(p.tls)} of it TLS)` : ""}`
|
|
1915
|
+
},
|
|
1916
|
+
{
|
|
1917
|
+
key: "wait",
|
|
1918
|
+
ms: p.wait ?? 0,
|
|
1919
|
+
text: `${ms(p.wait ?? 0)} waiting for the server's answer`
|
|
1920
|
+
}
|
|
1921
|
+
].filter((s) => s.ms >= 1);
|
|
1922
|
+
if (!steps.length) return null;
|
|
1923
|
+
const top = steps.reduce((a, b) => b.ms > a.ms ? b : a);
|
|
1924
|
+
const st = (page.visit?.nav?.server_timing ?? []).filter((s) => s.ms >= 1).sort((a, b) => b.ms - a.ms).slice(0, 4);
|
|
1925
|
+
const said = st.length ? ` The server's Server-Timing says: ${list(st.map((s) => `${s.desc || s.name} ${ms(s.ms)}`))}.` : "";
|
|
1926
|
+
const ssr = (page.visit?.nav?.server_timing ?? []).find((s) => s.name === "ssr");
|
|
1927
|
+
const beyond = ssr && p.wait ? Math.max(0, p.wait - ssr.ms) : 0;
|
|
1928
|
+
const compiling = !!page.dev && top.key === "wait" && beyond >= Math.max(300, (p.wait ?? 0) / 2);
|
|
1929
|
+
const split = top.key === "wait" && ssr && beyond >= 200 ? ` Of the ${ms(p.wait ?? 0)} wait, the render was ${ms(ssr.ms)}; the other ${ms(beyond)} came before it${compiling ? " — on the dev server, the page compiling on its first request" : " (a hook, a proxy, a cold start)"}.` : "";
|
|
1930
|
+
const fix = compiling ? "Reload the page: on the dev server a page is compiled on its first request, and that is most of this first byte. A build (or a second visit) shows the real one." : top.key === "redirect" ? "The redirects are the cost: link to the final address (the trailing slash, https, the locale) so the browser asks once." : top.key === "worker" ? "The service worker's start is the cost: keep it small, or turn on navigation preload so the page's request leaves while it starts." : top.key === "dns" || top.key === "connect" ? "Reaching the server is the cost: serve the page from closer to the visitor (a CDN at the edge), and keep the connection modern (HTTP/2 or 3, TLS 1.3)." : page.server_profiles && page.server_profile?.prerendered ? "The page is a prerendered file, and still slow to arrive: serve it from close to the visitor (a CDN keeps the built files at the edge), and check what stands in front of the files — a function or a middleware answering them first, a cold start, a slow origin." : "The server's answer is the cost: profile the page (its report names the slow load and the lines in it), cache the HTML where it is the same for everyone, or stream it so the first byte leaves before the slow part.";
|
|
1931
|
+
const profiled = page.server_profiles && top.key === "wait" && !compiling ? server_says(page.server_profile) : "";
|
|
1932
|
+
return {
|
|
1933
|
+
message: `Before the page's first byte: ${steps.map((s) => s.text).join(", ")}.${said}${split}${profiled}`,
|
|
1934
|
+
fix,
|
|
1935
|
+
fps: [],
|
|
1936
|
+
...compiling ? { note: true } : {}
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* THE WORST LAYOUT SHIFTS, EXPLAINED: CLS scores the worst burst of shifts (a session: shifts under a
|
|
1941
|
+
* second apart, five seconds at most). This names what moved in it (the island, else the element),
|
|
1942
|
+
* and what made it move — by what happened just before its biggest shift: a hole's answer swapped
|
|
1943
|
+
* in, an image arrived with no size set, a web font swapped in, an island hydrated. The fix is the
|
|
1944
|
+
* cause's; an unknown one says what to look at.
|
|
1945
|
+
*/
|
|
1946
|
+
function explain_cls(page, rows, name_of) {
|
|
1947
|
+
const shifts = [...page.shifts].sort((a, b) => a.t - b.t);
|
|
1948
|
+
if (!shifts.length) return null;
|
|
1949
|
+
let best = [];
|
|
1950
|
+
let best_sum = 0;
|
|
1951
|
+
let cur = [];
|
|
1952
|
+
let cur_sum = 0;
|
|
1953
|
+
for (const s of shifts) {
|
|
1954
|
+
if (cur.length && (s.t - cur[cur.length - 1].t > 1e3 || s.t - cur[0].t > 5e3)) {
|
|
1955
|
+
cur = [];
|
|
1956
|
+
cur_sum = 0;
|
|
1957
|
+
}
|
|
1958
|
+
cur.push(s);
|
|
1959
|
+
cur_sum += s.value;
|
|
1960
|
+
if (cur_sum > best_sum) {
|
|
1961
|
+
best = cur.slice();
|
|
1962
|
+
best_sum = cur_sum;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
if (!best.length) return null;
|
|
1966
|
+
const moved = /* @__PURE__ */ new Map();
|
|
1967
|
+
for (const s of best) {
|
|
1968
|
+
const who = s.fp ? name_of(s.fp) : s.tag || "an element";
|
|
1969
|
+
moved.set(who, (moved.get(who) ?? 0) + s.value);
|
|
1970
|
+
}
|
|
1971
|
+
const top = [...moved].sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
1972
|
+
const round3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
1973
|
+
const big = best.reduce((a, b) => b.value > a.value ? b : a);
|
|
1974
|
+
const WINDOW = 250;
|
|
1975
|
+
const file = (u) => {
|
|
1976
|
+
const q = u.indexOf("?");
|
|
1977
|
+
const s = q === -1 ? u : u.slice(0, q);
|
|
1978
|
+
return s.slice(s.lastIndexOf("/") + 1) || u;
|
|
1979
|
+
};
|
|
1980
|
+
const causes = [];
|
|
1981
|
+
for (const h of page.hole_waits ?? []) if (h.shown_at !== void 0) causes.push({
|
|
1982
|
+
at: h.shown_at + h.wait_ms,
|
|
1983
|
+
kind: "hole",
|
|
1984
|
+
name: h.name
|
|
1985
|
+
});
|
|
1986
|
+
for (const r of page.visit?.resources ?? []) if (r.type === "img") causes.push({
|
|
1987
|
+
at: r.end,
|
|
1988
|
+
kind: "img",
|
|
1989
|
+
name: file(r.url)
|
|
1990
|
+
});
|
|
1991
|
+
else if (r.type === "font") causes.push({
|
|
1992
|
+
at: r.end,
|
|
1993
|
+
kind: "font",
|
|
1994
|
+
name: file(r.url)
|
|
1995
|
+
});
|
|
1996
|
+
else if (page.dev && r.url.includes("type=style")) causes.push({
|
|
1997
|
+
at: r.end,
|
|
1998
|
+
kind: "devcss",
|
|
1999
|
+
name: file(r.url)
|
|
2000
|
+
});
|
|
2001
|
+
for (const r of rows) causes.push({
|
|
2002
|
+
at: r.done,
|
|
2003
|
+
kind: "island",
|
|
2004
|
+
name: r.name
|
|
2005
|
+
});
|
|
2006
|
+
let cause;
|
|
2007
|
+
for (const c of causes) if (c.at <= big.t + 5 && big.t - c.at <= WINDOW && (!cause || c.at > cause.at)) cause = c;
|
|
2008
|
+
const because = !cause ? "" : cause.kind === "hole" ? ` It came right after ${cause.name.startsWith("the hole") ? cause.name : `the hole ${cause.name}`}'s answer swapped in: the answer is not the size of its fallback.` : cause.kind === "img" ? ` It came right after the image ${cause.name} arrived: it had no size set, so the page made room when it loaded.` : cause.kind === "font" ? ` It came right after the web font ${cause.name} arrived: text re-laid out in it.` : cause.kind === "devcss" ? ` It came right after ${cause.name}'s styles arrived: the dev server adds a component's CSS with JavaScript after the first paint, so the page re-laid out in them. A build links that CSS in the head — this shift is the dev server's, not your page's.` : ` It came right after ${cause.name} hydrated: the island's size changed as it woke.`;
|
|
2009
|
+
const fix = !cause ? "Nothing the page timed explains it: look for content added above what is on screen (a banner, an ad, a late script), or an animation that moves layout instead of `transform`." : cause.kind === "hole" ? "Give the hole a fallback the size of its answer (a placeholder with a fixed or `min-height`), so the swap changes nothing around it." : cause.kind === "img" ? "Set the image's `width` and `height` (or an `aspect-ratio`) so its space is kept before it loads." : cause.kind === "font" ? "Match the fallback font's metrics (`size-adjust`, `ascent-override`), or preload the font, so the swap does not move text." : cause.kind === "devcss" ? "Nothing to fix for this shift: check the page on a build (preview or deploy), where the CSS is in the head before the first paint." : "Render the island at its final size on the server (the same content, or a placeholder of that height), so waking it changes nothing on screen.";
|
|
2010
|
+
return {
|
|
2011
|
+
message: `The worst burst of shifts added ${round3(best_sum)}, ${best.length === 1 ? "in one shift" : `over ${best.length} shifts in ${Math.round(best[best.length - 1].t - best[0].t)} ms`}: what moved was ${list(top.map(([n, v]) => `${n} (${round3(v)})`))}.${because}`,
|
|
2012
|
+
fix,
|
|
2013
|
+
fps: [...new Set(best.map((s) => s.fp).filter((f) => !!f))],
|
|
2014
|
+
...cause?.kind === "devcss" ? { note: true } : {}
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
/** One long-frame script, told: `BusyTimer.svelte's planted_busy_timer (a timer, 400 ms)`. */
|
|
2018
|
+
function describe_script(s) {
|
|
2019
|
+
let who = s.island ?? "";
|
|
2020
|
+
if (!who) {
|
|
2021
|
+
const q = s.url.indexOf("?");
|
|
2022
|
+
const p = q === -1 ? s.url : s.url.slice(0, q);
|
|
2023
|
+
who = p.slice(p.lastIndexOf("/") + 1) || "a script";
|
|
2024
|
+
}
|
|
2025
|
+
const kind = script_kind(s.invoker);
|
|
2026
|
+
return `${who}${s.fn ? `'s ${s.fn}` : ""} (${kind ? `${kind}, ` : ""}${Math.round(s.ms)} ms)`;
|
|
2027
|
+
}
|
|
2028
|
+
/** What ran a long-frame script, from its invoker: a timer, an animation frame, a promise, … */
|
|
2029
|
+
function script_kind(inv) {
|
|
2030
|
+
return inv.startsWith("TimerHandler") ? "a timer" : inv.startsWith("FrameRequestCallback") ? "an animation frame" : inv.includes("requestIdleCallback") ? "an idle callback" : inv.includes(".then") || inv.startsWith("Promise") ? "a promise" : inv.includes(".on") ? "an event handler" : "";
|
|
2031
|
+
}
|
|
2032
|
+
/** THE SLOWEST INTERACTION, explained: where it landed, which phase cost the time, and what held
|
|
2033
|
+
* it — an island hydrating, a script in a long frame, the handler's own code, or the paint. */
|
|
2034
|
+
function explain_interaction(i, rows, name_of, origin, cpu) {
|
|
2035
|
+
if (!i || !(i.delay + i.processing + i.presentation > 0)) return null;
|
|
2036
|
+
const what = i.name === "click" || i.name.startsWith("pointer") || i.name.startsWith("mouse") ? "a click" : i.name.startsWith("key") ? "a key press" : `a ${i.name}`;
|
|
2037
|
+
const where = i.fp ? `in ${name_of(i.fp)}` : "outside any island";
|
|
2038
|
+
const on = i.target ? ` on ${i.target}` : "";
|
|
2039
|
+
const ms = (n) => `${Math.round(n)} ms`;
|
|
2040
|
+
const phases = [
|
|
2041
|
+
{
|
|
2042
|
+
key: "delay",
|
|
2043
|
+
ms: i.delay,
|
|
2044
|
+
text: `${ms(i.delay)} before its handlers could run`
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
key: "handler",
|
|
2048
|
+
ms: i.processing,
|
|
2049
|
+
text: `${ms(i.processing)} in its handlers`
|
|
2050
|
+
},
|
|
2051
|
+
{
|
|
2052
|
+
key: "paint",
|
|
2053
|
+
ms: i.presentation,
|
|
2054
|
+
text: `${ms(i.presentation)} to paint the next frame`
|
|
2055
|
+
}
|
|
2056
|
+
];
|
|
2057
|
+
const top = phases.reduce((a, b) => b.ms > a.ms ? b : a);
|
|
2058
|
+
const scripts = i.scripts ?? [];
|
|
2059
|
+
const of_phase = (p) => scripts.filter((s) => s.phase === p);
|
|
2060
|
+
const wait_end = i.t + i.delay;
|
|
2061
|
+
const hydrating = rows.filter((r) => r.fp !== i.fp && Math.min(r.done, wait_end) - Math.max(r.done - r.hydrate_ms, i.t) > 1);
|
|
2062
|
+
const behind = hydrating.length ? `${list(hydrating.map((r) => r.name))} ${hydrating.length === 1 ? "was" : "were"} hydrating` : of_phase("delay").length ? `the main thread was running ${list(of_phase("delay").slice(0, 3).map(describe_script))}` : "";
|
|
2063
|
+
const handler = of_phase("handler")[0];
|
|
2064
|
+
const own_origin = (url) => {
|
|
2065
|
+
if (!origin) return true;
|
|
2066
|
+
try {
|
|
2067
|
+
return new URL(url, origin).origin === origin;
|
|
2068
|
+
} catch {
|
|
2069
|
+
return true;
|
|
2070
|
+
}
|
|
2071
|
+
};
|
|
2072
|
+
const sampled = cpu && Math.abs(cpu.t - i.t) < 2 ? cpu : void 0;
|
|
2073
|
+
const readable = (f) => f.file.endsWith(".svelte") || f.file.startsWith("src/") || f.file.includes("/src/");
|
|
2074
|
+
const pick = (s) => {
|
|
2075
|
+
const f = s?.top.find((x) => x.kind === "app") ?? s?.top[0];
|
|
2076
|
+
return f && readable(f) ? f : void 0;
|
|
2077
|
+
};
|
|
2078
|
+
const handler_fn = pick(sampled?.handler);
|
|
2079
|
+
const wait_fn = pick(sampled?.wait);
|
|
2080
|
+
const fn_label$1 = (f) => sampled?.mapped ? fn_label(f) : fn_label({
|
|
2081
|
+
...f,
|
|
2082
|
+
line: null
|
|
2083
|
+
});
|
|
2084
|
+
const own_handler = !!handler && !!i.fp && handler.invoker.includes(".on") && own_origin(handler.url);
|
|
2085
|
+
const handler_text = handler_fn ? `${own_handler || !handler && i.fp ? `${name_of(i.fp)}'s ${fn_label$1(handler_fn)}` : fn_label$1(handler_fn)}, ${Math.round(handler_fn.self_ms)} ms sampled` : !handler ? "" : own_handler ? `${name_of(i.fp)}'s own ${i.name} handler` : describe_script(handler);
|
|
2086
|
+
const wait_text = hydrating.length ? behind : wait_fn ? `the main thread was running ${fn_label$1(wait_fn)}, ${Math.round(wait_fn.self_ms)} ms sampled${of_phase("delay")[0] && script_kind(of_phase("delay")[0].invoker) ? `, run by ${script_kind(of_phase("delay")[0].invoker)}` : ""}` : behind;
|
|
2087
|
+
const forced_ms = of_phase("handler").reduce((s, x) => s + (x.forced ?? 0), 0);
|
|
2088
|
+
const forced = forced_ms >= 16 && forced_ms >= i.processing * .3 ? Math.min(forced_ms, i.processing) : 0;
|
|
2089
|
+
const forced_text = forced ? `; ${ms(forced)} of it the browser laying the page out again because the code read sizes after changing it` : "";
|
|
2090
|
+
const parts = phases.map((p) => {
|
|
2091
|
+
if (p.key === "delay" && wait_text && p.ms >= 16) return `${p.text} (${wait_text})`;
|
|
2092
|
+
if (p.key === "handler" && handler_text && p.ms >= 16) return `${p.text} (mostly ${handler_text}${forced_text})`;
|
|
2093
|
+
if (p.key === "handler" && forced) return `${p.text} (${forced_text.slice(2)})`;
|
|
2094
|
+
return p.text;
|
|
2095
|
+
});
|
|
2096
|
+
return {
|
|
2097
|
+
message: `The slowest was ${what}${on} ${where}: ${parts[0]}, ${parts[1]}, ${parts[2]}.`,
|
|
2098
|
+
fix: top.key === "delay" ? hydrating.length ? "The input waited for islands to hydrate: wake the ones not needed at once later (wake=\"visible\" or \"idle\"), or make their hydration lighter, so a click is never queued behind it." : "The input waited for other work on the main thread: split that long task (yield between steps with `await new Promise(r => setTimeout(r))`), or move it off the main thread, or run it when the visitor is not interacting." : top.key === "handler" ? forced ? "Much of the handler is forced layout: it reads a size or position (offsetHeight, getBoundingClientRect, getComputedStyle) after changing the page, and the browser lays the page out again for each read. Read every size first, then make the changes (or batch them in one requestAnimationFrame); never read between writes in a loop." : "The handler itself is the cost: update the screen first and do the heavy part after the next frame (`requestAnimationFrame` then `setTimeout`), do less per event, or move the work to a worker." : "Painting the result is the cost: the handler changed a lot of the page. Change less per update (a shorter list, `content-visibility: auto` for off-screen parts), and avoid reading layout right after writing it.",
|
|
2099
|
+
fps: i.fp ? [i.fp] : []
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
/** Names with their copies counted: `BatchHole ×4`, `Menu and Cart ×2`. */
|
|
2103
|
+
/**
|
|
2104
|
+
* On the dev server: how much of an island's code load was the server COMPILING files on this
|
|
2105
|
+
* request — the time (overlaps counted once) the server took to answer each file fetched in its load
|
|
2106
|
+
* window, when that took 80 ms or more (a transform: a warm dev server answers in a few). 0 off the
|
|
2107
|
+
* dev server, or when the visit does not list the files.
|
|
2108
|
+
*/
|
|
2109
|
+
function dev_compile_ms(page, r) {
|
|
2110
|
+
if (!page.dev) return 0;
|
|
2111
|
+
const from = r.t0 - 5;
|
|
2112
|
+
const to = r.t0 + r.load_ms + 5;
|
|
2113
|
+
const spans = [];
|
|
2114
|
+
for (const f of page.visit?.resources ?? []) {
|
|
2115
|
+
if (f.req_start === void 0 || f.res_start === void 0 || f.req_start < from || f.res_start > to) continue;
|
|
2116
|
+
if (f.res_start - f.req_start >= 80) spans.push([f.req_start, f.res_start]);
|
|
2117
|
+
}
|
|
2118
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
2119
|
+
let total = 0;
|
|
2120
|
+
let end = -Infinity;
|
|
2121
|
+
for (const [a, b] of spans) {
|
|
2122
|
+
if (b <= end) continue;
|
|
2123
|
+
total += b - Math.max(a, end);
|
|
2124
|
+
end = b;
|
|
2125
|
+
}
|
|
2126
|
+
return total;
|
|
2127
|
+
}
|
|
2128
|
+
function counted(names) {
|
|
2129
|
+
const n = /* @__PURE__ */ new Map();
|
|
2130
|
+
for (const s of names) n.set(s, (n.get(s) ?? 0) + 1);
|
|
2131
|
+
return list([...n].map(([s, c]) => c > 1 ? `${s} ×${c}` : s));
|
|
2132
|
+
}
|
|
2133
|
+
/** A URL's path (and query), for naming where something answered from. */
|
|
2134
|
+
function path_of(url) {
|
|
2135
|
+
try {
|
|
2136
|
+
const u = new URL(url, "http://x");
|
|
2137
|
+
return u.pathname + u.search;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return url;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
function list(names) {
|
|
2143
|
+
const uniq = [...new Set(names)];
|
|
2144
|
+
if (uniq.length <= 1) return uniq[0] ?? "";
|
|
2145
|
+
if (uniq.length <= 4) return uniq.slice(0, -1).join(", ") + " and " + uniq[uniq.length - 1];
|
|
2146
|
+
return uniq.slice(0, 3).join(", ") + ` and ${uniq.length - 3} more`;
|
|
2147
|
+
}
|
|
2148
|
+
function clip(s) {
|
|
2149
|
+
const one = s.split("\n").join(" ");
|
|
2150
|
+
return one.length > 70 ? one.slice(0, 69) + "…" : one;
|
|
2151
|
+
}
|
|
2152
|
+
function short(url) {
|
|
2153
|
+
const q = url.indexOf("?");
|
|
2154
|
+
const path = q === -1 ? url : url.slice(0, q);
|
|
2155
|
+
return path.slice(path.lastIndexOf("/") + 1) || path;
|
|
2156
|
+
}
|
|
2157
|
+
export { DOM_LARGE, analyze_page, boolean_attr_at, browser_of, defer_keys, dev_compile_ms, dev_two_copies, encoding_only, engine_of, explain_held_open, first_difference, inline_threshold_tune, late_found, lcp_font, lcp_rivals, prebundled_names, rate, vital_parts, vitals_moved };
|