diff --git a/CHANGELOG.md b/CHANGELOG.md index 272f5b37..2780ca80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ updating the website alone cannot change an installed 1.0.0 image. - Release-facing Web illustrations are recaptured in English from the current production bundles. Their provenance identifies simulated USB/sample data separately from read-only directory queries and physical-device acceptance. +- Closing a shared-track map during zoom no longer leaves Leaflet's completion + callback accessing a destroyed view. Track maps share a public-option + workaround, with a browser regression that reproduces the former failure. ### Changed diff --git a/docs/engineering/web-ui-integration.md b/docs/engineering/web-ui-integration.md index dfd01abd..b16f5431 100644 --- a/docs/engineering/web-ui-integration.md +++ b/docs/engineering/web-ui-integration.md @@ -37,6 +37,14 @@ Long headings must wrap without losing the Title ribbon. Test narrow layouts and RTL with actual translated copy. Unpublished drafts, dirty configuration, keyboard focus and map state must survive language changes. +Disposable track maps use `createTrackMap`, with Leaflet's public +`zoomAnimation: false` option. Leaflet 1.9.4 retains a zoom completion timer +after `remove()`, which can access a destroyed map when a view is closed during +zoom. The shared-package browser regression triggers that sequence and lets the +250-ms callback window expire. Dragging, pan animation, zoom controls and fitting +still work. Re-evaluate this option after adopting a stable Leaflet release with +the [upstream lifecycle fix](https://github.com/Leaflet/Leaflet/issues/9575). + ## Verification Build all four site entry points after a dependency change. Run the locale and diff --git a/site/tracks/src/leaflet-map.js b/site/tracks/src/leaflet-map.js new file mode 100644 index 00000000..c03380e0 --- /dev/null +++ b/site/tracks/src/leaflet-map.js @@ -0,0 +1,9 @@ +import L from 'leaflet'; + +// Leaflet 1.9.4 leaves a zoom completion timer alive after remove(). The +// public option avoids that callback on disposable React map views while +// retaining dragging, pan animation, zoom controls and fitBounds. +// Upstream: https://github.com/Leaflet/Leaflet/issues/9575 +export function createTrackMap(host){ + return L.map(host,{preferCanvas:true,zoomAnimation:false}).setView([25,110],3); +} diff --git a/site/tracks/src/page.jsx b/site/tracks/src/page.jsx index 0a1ed635..1d03aebf 100644 --- a/site/tracks/src/page.jsx +++ b/site/tracks/src/page.jsx @@ -2,6 +2,7 @@ import React,{useEffect,useMemo,useRef,useState} from 'react'; import {createRoot} from 'react-dom/client'; import {Background,Button,Card,Title} from 'animal-island-ui'; import L from 'leaflet'; +import {createTrackMap} from './leaflet-map.js'; import 'animal-island-ui/style'; import 'leaflet/dist/leaflet.css'; import '../../styles.css'; @@ -77,7 +78,7 @@ function Preview({model,draft,selected,onSelect,onPoint,picking,coordinateBounds const operationDrawing=useRef(); point.current=onPoint;pick.current=picking;select.current=onSelect; useEffect(()=>{ - const view=L.map(host.current,{preferCanvas:true}).setView([25,110],3);map.current=view; + const view=createTrackMap(host.current);map.current=view; view.createPane('land');view.getPane('land').style.zIndex=200; const land=L.geoJSON(null,{pane:'land',renderer:L.canvas({pane:'land'}),interactive:false,style:{color:'#c5d4cb',weight:1,fillColor:'#f4efd8',fillOpacity:.85}}).addTo(view); drawing.current=L.featureGroup().addTo(view);operationDrawing.current=L.featureGroup().addTo(view);let countries,cancelled=false; diff --git a/site/tracks/src/shared-track-browse.jsx b/site/tracks/src/shared-track-browse.jsx index 85bec3b9..37b30149 100644 --- a/site/tracks/src/shared-track-browse.jsx +++ b/site/tracks/src/shared-track-browse.jsx @@ -1,6 +1,7 @@ import React,{useEffect,useMemo,useRef,useState} from 'react'; import {Button} from 'animal-island-ui'; import L from 'leaflet'; +import {createTrackMap} from './leaflet-map.js'; import {useManagementLocale} from '../../i18n/management-copy.jsx'; import {trackBrowseText} from '../../i18n/track-browse-labels.js'; import {regionDisplayGeometry} from '../../geocaching/src/country-boundaries.js'; @@ -30,7 +31,7 @@ export function SharedTrackBrowse({onQuery,onRegion,onOpen,disabled=false,canUse const [bounds,setBounds]=useState(null),[automatic,setAutomatic]=useState(false),[items,setItems]=useState([]),[loading,setLoading]=useState(false),[notice,setNotice]=useState({key:'intro'}); const epoch=useRef(0),controller=useRef(),last=useRef(''); useEffect(()=>{ - const view=L.map(host.current,{preferCanvas:true}).setView([25,110],3);map.current=view; + const view=createTrackMap(host.current);map.current=view; view.createPane('land');view.getPane('land').style.zIndex=200; const land=L.geoJSON(null,{pane:'land',renderer:L.canvas({pane:'land'}),interactive:false,style:{color:'#c5d4cb',weight:1,fillColor:'#f4efd8',fillOpacity:.85}}).addTo(view);drawing.current=L.featureGroup().addTo(view); let countries,cancelled=false; diff --git a/site/tracks/src/shared-track-preview.jsx b/site/tracks/src/shared-track-preview.jsx index 783fb1b3..932070b0 100644 --- a/site/tracks/src/shared-track-preview.jsx +++ b/site/tracks/src/shared-track-preview.jsx @@ -1,5 +1,6 @@ import React,{useEffect,useMemo,useRef,useState} from 'react'; import L from 'leaflet'; +import {createTrackMap} from './leaflet-map.js'; import {parseKml} from './kml.js'; import {summarizeKml} from './kml-summary.js'; import {KmlContentDetails} from './kml-content-details.jsx'; @@ -40,7 +41,7 @@ export function SharedTrackPreview({detail,packet}){ const number=new Intl.NumberFormat(locale),fixed=(value,digits)=>new Intl.NumberFormat(locale,{maximumFractionDigits:digits}).format(value); const format=(key,values)=>text[key].replace(/\{(\w+)\}/g,(_,name)=>values[name]); useEffect(()=>{ - const view=L.map(host.current,{preferCanvas:true}).setView([25,110],3);map.current=view; + const view=createTrackMap(host.current);map.current=view; view.createPane('land');view.getPane('land').style.zIndex=200; const land=L.geoJSON(null,{pane:'land',renderer:L.canvas({pane:'land'}),interactive:false,style:{color:'#c5d4cb',weight:1,fillColor:'#f4efd8',fillOpacity:.85}}).addTo(view); drawing.current=L.featureGroup().addTo(view);let countries,cancelled=false; diff --git a/tests/site/track-shared-package.browser.mjs b/tests/site/track-shared-package.browser.mjs index 76081263..3819ed4b 100644 --- a/tests/site/track-shared-package.browser.mjs +++ b/tests/site/track-shared-package.browser.mjs @@ -14,6 +14,8 @@ import {trackSourceChangeConfirmation} from '../../site/i18n/track-source-labels import {trackArchiveText} from '../../site/i18n/track-archive-labels.js'; import {readTrackPackageArchive} from '../../site/tracks/src/package-archive.js'; const contents=`import React,{useState} from 'react';import {createRoot} from 'react-dom/client'; +import L from 'leaflet'; +L.Map.addInitHook(function(){if(this.getContainer().classList.contains('shared-track-map'))globalThis.previewMap=this;}); import 'animal-island-ui/style';import './site/styles.css';import './site/tracks/src/tracks.css'; import {SharedTrackPackagePanel} from './site/tracks/src/shared-track-package-panel.jsx';import {ManagementCopyProvider} from './site/i18n/management-copy.jsx'; import {TrackSharedRead} from './site/tracks/src/track-shared-read.jsx'; @@ -42,6 +44,14 @@ globalThis.connectUSB=async(own=false)=>{ }; globalThis.importSharedCopy=async(file,{packageSource})=>{const row=await saveTrackDraft(new Uint8Array(await file.arrayBuffer()),{name:'Shared copy',filename:file.name,packageSource});globalThis.copiedDraft=row;return true;}; globalThis.inspectCopy=async()=>{globalThis.inspecting=await readTrackDraft(copiedDraft.id);rerender();}; +globalThis.inspectCopyDuringZoom=async()=>{ + const view=globalThis.previewMap;if(!view)throw Error('Shared preview map missing'); + await new Promise(resolve=>{view.once('zoomanim',resolve);view.setZoom(view.getZoom()+1,{animate:true});requestAnimationFrame(()=>requestAnimationFrame(resolve));}); + await globalThis.inspectCopy(); + // Leaflet 1.9 completes zoom using a 250-ms fallback timer. Let it run after + // React removes the preview so this checks disposal rather than hiding it. + await new Promise(resolve=>setTimeout(resolve,350)); +}; globalThis.copyState=async()=>{const row=await readTrackDraft(copiedDraft.id),options=await draftPackageCreationOptions(row);return {version:row.version,template:Boolean(row.attachmentTemplate),proof:Boolean(row.sourceProof),count:options.assets[2],source:options.metadata[7].map(hex)};}; globalThis.editCopy=async()=>{const old=await readTrackDraft(copiedDraft.id),changed=new Uint8Array(old.source.length+1);changed.set(old.source);changed[changed.length-1]=10;const saved=await saveTrackDraft(changed,{id:old.id,expectedVersion:old.version,name:old.name,filename:old.filename}),row=await readTrackDraft(saved.id),options=await draftPackageCreationOptions(row),actor=await Identity.generate(),plan=await createTrackPublicationPlan(row.source,{verified:true,publicKey:await actor.getPublicKey(),generation:1},{...options,title:row.name}),packet=await draftPackageInputs(row,plan,{persist:false});return {templatePreserved:hex(new Uint8Array(row.attachmentTemplate))===hex(index),rebound:hex(packet.attachmentIndex)!==hex(index),declared:options.assets[2],files:packet.attachmentFiles.length,proofPreserved:hex(new Uint8Array(row.sourceProof))===hex(new Uint8Array(old.sourceProof))};}; globalThis.badCopy=async()=>{const before=(await listTrackDrafts()).length,bad=photo.slice();bad[0]^=1;let rejected=false;try{await saveTrackDraft(source,{name:'Bad copy',packageSource:{signed,kml:source,attachmentIndex:index,attachmentFiles:[{id:'photo',content:bad}]}});}catch{rejected=true;}return {rejected,unchanged:(await listTrackDrafts()).length===before};}; @@ -57,7 +67,7 @@ const js=bundle.outputFiles.find(file=>file.path.endsWith('.js')).text,css=bundl const server=http.createServer((req,res)=>{res.setHeader('Content-Type','text/html');res.end('
');});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); const browser=await chromium.launch({channel:process.env.TRAIL_MATE_TEST_BROWSER_CHANNEL,headless:true}); try{ - const page=await browser.newPage({viewport:{width:1200,height:1000}}),origin=`http://127.0.0.1:${server.address().port}`,requests=[],errors=[];page.on('request',req=>requests.push(req.url()));page.on('pageerror',error=>errors.push(String(error))); + const page=await browser.newPage({viewport:{width:1200,height:1000}}),origin=`http://127.0.0.1:${server.address().port}`,requests=[],errors=[];page.on('request',req=>requests.push(req.url()));page.on('pageerror',error=>{errors.push(String(error));console.error(error.stack);}); await page.goto(origin);await page.addStyleTag({content:css});await page.addScriptTag({content:js});const photo=await readFile('tests/hostlink/fixtures/png-cache/red-16x8.jpg');await page.evaluate(bytes=>start(bytes),Array.from(photo)); await page.getByRole('button',{name:'下载并校验附件',exact:true}).click();await page.getByText('文件校验通过',{exact:false}).waitFor();await page.getByText(/可选文件不可用/).waitFor();assert.equal(await page.locator('.shared-track-resource').count(),2);assert.equal(await page.evaluate(()=>currentPacket.ready),true); const first=page.locator('.shared-track-resource').first();await first.getByRole('button',{name:'预览图片',exact:true}).click();await page.waitForFunction(()=>document.querySelector('.shared-resource-preview img')?.naturalWidth===16);const previewURL=await page.locator('.shared-resource-preview img').getAttribute('src'); @@ -79,7 +89,7 @@ try{ await page.screenshot({path:'.codex-build/ui-check/package-install-in-detail.png',fullPage:true});await page.setViewportSize({width:390,height:844});await page.screenshot({path:'.codex-build/ui-check/package-install-in-detail-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1),false); await page.evaluate(()=>connectUSB(true));const maintenance=trackMaintenanceText('zh-Hans');await page.getByRole('button',{name:maintenance.archive,exact:true}).click();await page.getByText(maintenance.saved,{exact:true}).waitFor();assert.deepEqual(await page.evaluate(()=>checkArchive()),{rows:1,declared:2,sameIndex:true,samePhoto:true,archive:true}); assert.deepEqual(await page.evaluate(()=>editCopy()),{templatePreserved:true,rebound:true,declared:2,files:1,proofPreserved:true});assert.deepEqual(await page.evaluate(()=>badCopy()),{rejected:true,unchanged:true}); - await page.evaluate(()=>inspectCopy());await page.locator('.tracks-attachments').getByText('notes.txt',{exact:true}).waitFor(); + await page.evaluate(()=>inspectCopyDuringZoom());await page.locator('.tracks-attachments').getByText('notes.txt',{exact:true}).waitFor(); for(const {id} of locales){assert.ok(trackSourceChangeConfirmation(id));await page.evaluate(id=>setLocale(id),id);await page.getByText(trackPackageText(id).optionalMissing,{exact:false}).waitFor();} await page.evaluate(()=>setLocale('zh-Hans'));await page.screenshot({path:'.codex-build/ui-check/shared-draft-resources-mobile.png',fullPage:true}); const attachmentText=trackAttachmentsText('zh-Hans'),before=await page.evaluate(()=>copyState());await page.getByLabel(attachmentText.required,{exact:true}).uncheck();page.removeAllListeners('dialog');page.once('dialog',dialog=>dialog.dismiss());await page.getByRole('button',{name:attachmentText.save,exact:true}).click();assert.deepEqual(await page.evaluate(()=>copyState()),before);