mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-23 05:32:18 +00:00
fix: prevent Leaflet callbacks after track preview disposal
This commit is contained in:
1 parent
1721403ad2
commit
f3e66efdf1
7 files changed
+38
-5
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user