fix: prevent Leaflet callbacks after track preview disposal

This commit is contained in:
liu weikai committed 2026-10-17 10:00:45 +08:00
1 parent 1721403ad2
commit f3e66efdf1
7 files changed
+38 -5

No files matched your search

+3
View File
@@ -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
+8
View File
@@ -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
+9
View File
@@ -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);
}
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+12 -2
View File
@@ -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('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><div id="root"></div>');});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);