From 3d4cd4004e00caff55ce5ab74f9963acc456aae0 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:34:46 +0000 Subject: [PATCH] Plugin studio: host attributes stay on the host; map-load measurement probe inheritAttrs:false on the studio root, so a host's attributes (style, class, id) stay on the element instead of also landing on the layout inside it: the trap that made the Prayer Walk Planner 76px tall in the gallery. Layout verified unchanged in four hosts on the built bundle: its own page (shell 1280x900, body 836), a bare partner snippet (85vh default), a host that sets its own inline height (the style stays on the host; the shell root has no style attribute), and the app-shell mounter from BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN, display:block forced on the host: body 656px, rail and tree rendered. That bug no longer reproduces on the dx-maps side. internal/measure-map-load.mjs: per production map, the cold, throttled waterfall (bundle, engine script, style, data, basemap up, visible tiles loaded) on a served built tree, from the leaf page or, with --embed, from a partner page that pastes only the embed snippet. It showed that Vue is already inlined in every bundle and that the engine waits on the bundle only on partner pages. --- internal/measure-map-load.mjs | 109 ++++++++++++++++++ .../plugin-studio/plugin-studio.vue | 5 + 2 files changed, 114 insertions(+) create mode 100644 internal/measure-map-load.mjs diff --git a/internal/measure-map-load.mjs b/internal/measure-map-load.mjs new file mode 100644 index 000000000..dce13dce3 --- /dev/null +++ b/internal/measure-map-load.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * measure-map-load — what each production map fetches before first render, and how long it takes. + * + * USAGE: node internal/measure-map-load.mjs [--runs 3] [--mbps 10] [--rtt 60] [--only bundle/profile,…] + * --mbps 0 = no throttling. Serves the BUILT tree itself on a free port (serve-strict). + * Prints, per map: median time to the bundle, to the engine script (mapbox-gl.js), to the map's + * `load` event, and the serial chain — the requests that had to finish before the next could start. + * + * ★ WHY. The Driver: "Vue needs to be bundled inline into the maps so map load time is faster." Vue is + * already compiled into every IIFE (vite.config.js externalises only jspdf), so the measurement has + * to find what DOES wait on what. Every run is a FRESH browser context (cold HTTP cache) under + * Chrome DevTools network throttling, so a local server is not mistaken for a real connection. + */ +import { chromium } from 'playwright' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { readFileSync } from 'node:fs' + +const argv = process.argv.slice(2) +const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d } +const RUNS = Number(arg('--runs', 3)), MBPS = Number(arg('--mbps', 10)), RTT = Number(arg('--rtt', 60)) +// --embed: measure the PARTNER case — a page on another origin that pastes only the manifest's +// embed snippet (no leaf page, so nothing preloads the engine). Default: the map's own leaf page. +const EMBED = argv.includes('--embed') +const DEFAULT = JSON.parse(readFileSync('plugin-designs/doxa-maps-spa/doxa-maps-spa.meta.json', 'utf8')).composes +const ONLY = arg('--only', '') ? arg('--only', '').split(',') : DEFAULT +const manifest = JSON.parse(readFileSync('public/doxa-maps/manifest.json', 'utf8')) + +const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) +const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(port)], { stdio: 'ignore' }) +const hostPort = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) }) +let hostHtml = '' +const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); r.end(hostHtml) }).listen(hostPort, 'localhost') +await new Promise((r) => setTimeout(r, 900)) +const T = `http://127.0.0.1:${port}/doxa-maps` + +const kind = (u) => /mapbox-gl(\.min)?\.js|maplibre-gl\.js/.test(u) ? 'engine' : /mapbox-gl\.css|maplibre-gl\.css/.test(u) ? 'engine-css' + : /\/styles\/v1\/|tiles\.openfreemap\.org\/styles/.test(u) ? 'style' : /\/doxa-maps\/[^/]+\/[^/]+\.js$/.test(u) ? 'bundle' + : /pray\.doxa\.life\/api/.test(u) ? 'api' : /\/assets\/.*\.(json|geojson)/.test(u) ? 'data' : /\.pbf|\.mvt|vector/.test(u) ? 'tile' : 'other' + +const browser = await chromium.launch({ args: ['--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] }) +const rows = [] +for (const ref of ONLY) { + const [bundle, profile] = ref.split('/') + const b = manifest.bundles.find((x) => x.bundle === bundle) + const p = b && b.profiles.find((x) => x.profile === profile) + if (!p) { rows.push({ ref, missing: true }); continue } + let url = `${T}/${String(p.iframeUrl).replace(/^\.\//, '')}` + if (EMBED) { + const tok = (readFileSync(`public/doxa-maps/${String(p.iframeUrl).replace(/^\.\//, '')}`, 'utf8').match(/BAKED_TOKEN\s*=\s*"(pk\.[^"]+)"/) || [])[1] || '' + const snip = String((p.embed && p.embed.script) || '').replace(/src="\.\//g, `src="${T}/`).replace(/YOUR_MAPBOX_TOKEN/g, tok) + hostHtml = `Partner
${snip}
` + url = `http://localhost:${hostPort}/partner.html` + } + const runs = [] + for (let r = 0; r < RUNS; r++) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }) + const page = await ctx.newPage() + if (MBPS > 0) { + const cdp = await ctx.newCDPSession(page) + await cdp.send('Network.enable') + await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: RTT, downloadThroughput: (MBPS * 1e6) / 8, uploadThroughput: (MBPS * 1e6) / 16 }) + } + const reqs = new Map() + let t0 = 0 + page.on('request', (q) => { reqs.set(q, { url: q.url(), kind: kind(q.url()), start: Date.now() - t0 }) }) + page.on('requestfinished', (q) => { const x = reqs.get(q); if (x) x.end = Date.now() - t0 }) + t0 = Date.now() + await page.goto(url, { waitUntil: 'commit' }) + let loaded = null, styled = null + const deadline = Date.now() + 60000 + while (Date.now() < deadline) { + const st = await page.evaluate(() => { + const find = (r) => { for (const e of r.querySelectorAll('*')) { if (e.__doxaMap) return e.__doxaMap; if (e.shadowRoot) { const m = find(e.shadowRoot); if (m) return m } } return null } + const m = find(document) + // FIRST RENDER = the style is loaded and the tiles in view are loaded. Not map.loaded(): it + // stays false while any animation runs, and several maps animate continuously (pulses, glow), + // so the first version of this probe waited out its full timeout on every run. + return { style: !!(m && m.isStyleLoaded && m.isStyleLoaded()), tiles: !!(m && m.isStyleLoaded && m.isStyleLoaded() && m.areTilesLoaded && m.areTilesLoaded()) } + }).catch(() => ({})) + if (st.style && styled === null) styled = Date.now() - t0 + if (st.tiles) { loaded = Date.now() - t0; break } + await page.waitForTimeout(100) + } + const all = [...reqs.values()] + const first = (k) => all.filter((x) => x.kind === k).sort((a, c) => a.start - c.start)[0] + const bundleReq = all.find((x) => x.kind === 'bundle' && x.url.includes(`/${bundle}/${bundle}.js`)) || first('bundle') + const eng = first('engine'), sty = first('style'), dat = first('data') + runs.push({ + loaded, styled, bundleEnd: bundleReq && bundleReq.end, engineStart: eng && eng.start, engineEnd: eng && eng.end, + styleStart: sty && sty.start, dataStart: dat && dat.start, + bytes: { bundle: 0 }, + }) + await ctx.close() + } + const med = (k) => { const v = runs.map((x) => x[k]).filter((x) => x != null).sort((a, c) => a - c); return v.length ? v[Math.floor(v.length / 2)] : null } + console.log(' measured ' + ref + ': ' + JSON.stringify(runs.map((x) => x.loaded))) + rows.push({ ref, styled: med('styled'), loaded: med('loaded'), bundleEnd: med('bundleEnd'), engineStart: med('engineStart'), engineEnd: med('engineEnd'), styleStart: med('styleStart'), dataStart: med('dataStart'), runs: runs.map((x) => x.loaded) }) +} +await browser.close(); srv.kill(); host.close() + +const f = (x) => (x == null ? '—' : (x / 1000).toFixed(2) + 's') +console.log(`\n${EMBED ? 'PARTNER EMBED (snippet on another origin)' : 'LEAF PAGE'} · throttle: ${MBPS > 0 ? MBPS + ' Mbps, ' + RTT + ' ms RTT' : 'none'} · ${RUNS} cold run(s) each · medians`) +console.log('map'.padEnd(46) + 'bundle done engine start engine done style start data start BASEMAP UP TILES LOADED (runs)') +for (const r of rows) { + if (r.missing) { console.log(r.ref.padEnd(46) + 'not in manifest'); continue } + console.log(r.ref.padEnd(46) + f(r.bundleEnd).padEnd(13) + f(r.engineStart).padEnd(14) + f(r.engineEnd).padEnd(13) + f(r.styleStart).padEnd(13) + f(r.dataStart).padEnd(12) + f(r.styled).padEnd(12) + f(r.loaded).padEnd(13) + JSON.stringify(r.runs.map((x) => (x == null ? null : +(x / 1000).toFixed(2))))) +} diff --git a/plugin-designs/plugin-studio/plugin-studio.vue b/plugin-designs/plugin-studio/plugin-studio.vue index 7f582659f..708794d04 100644 --- a/plugin-designs/plugin-studio/plugin-studio.vue +++ b/plugin-designs/plugin-studio/plugin-studio.vue @@ -367,6 +367,11 @@ function whenTagDefined(tag, ms) { export default defineComponent({ name: 'PluginStudio', + // A host's attributes (style="height:…", class, id) belong on the HOST, not on the + // layout inside it. Falling through, a host's inline style also landed on the shell root — the trap + // that made the Prayer Walk Planner 76px tall in the gallery. Everything the layout needs (dir, + // title, labels) is bound explicitly below. + inheritAttrs: false, components: { VerticalTabsLayout, StudioTree }, props: { treeBase: { type: String, default: '' },