mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
cf62567c48
* fix(map): drop the casing layer before its source on teardown
Tearing down the reservation overlay logged an error on every map unmount:
Source "trek-reservations" cannot be removed while layer
"trek-reservations-lines-transit-casing" is using it.
The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.
The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.
The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.
* fix(map): stop untrusted values reaching the marker HTML
Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.
place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like
/uploads/x" onerror="…" y="
satisfies that and then breaks out of the src="…" attribute.
category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.
Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.
Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.
The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.
Verified against the unpatched renderer — the three new cases fail without it.
* perf(client): serialize the map icons without react-dom/server
Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.
mapViewport chunk 189,532 -> 3,710 B
Tooltip chunk 194,333 -> 1,970 + 4,860 B
precache total 17,856 -> 17,675 KiB
utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.
Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.
TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.
An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
import type { ReactElement, ReactNode } from 'react'
|
|
|
|
/**
|
|
* Static-markup serializer for the lucide icons the map and PDF code turn into
|
|
* SVG strings.
|
|
*
|
|
* It replaces renderToStaticMarkup, whose Fizz runtime is ~190 kB raw / ~57 kB
|
|
* gzip of a chunk that three lazy route chunks share — including the Leaflet
|
|
* renderer, which is the default. For output that is always a single <svg> with
|
|
* a flat list of shape children, that is a poor trade.
|
|
*
|
|
* Deliberately generic over React elements rather than over lucide's icon
|
|
* tables: `iconNode` is closed over inside createLucideIcon and not exported, so
|
|
* calling the forwardRef render function is what keeps this working across
|
|
* lucide upgrades. iconMarkup.parity.test.ts diffs the output against real Fizz
|
|
* for every exported icon, so drift fails the suite instead of shipping.
|
|
*
|
|
* Byte-compatibility notes, all verified against react-dom 19.2.6:
|
|
* - React escapes & " ' < > the same way in attribute values and in text, so
|
|
* one escape function covers both.
|
|
* - Only the props in ATTRIBUTE_NAMES are renamed. Everything else keeps its
|
|
* literal name, which is what preserves the camelCase SVG attributes —
|
|
* viewBox has to stay viewBox and must NOT become view-box.
|
|
* - Fizz emits <path …></path>, never a self-closing tag. So do we.
|
|
*/
|
|
|
|
const FORWARD_REF = Symbol.for('react.forward_ref')
|
|
|
|
/** React's DOM property -> attribute renames, limited to what an SVG carries. */
|
|
const ATTRIBUTE_NAMES: Record<string, string> = {
|
|
className: 'class',
|
|
htmlFor: 'for',
|
|
strokeWidth: 'stroke-width',
|
|
strokeLinecap: 'stroke-linecap',
|
|
strokeLinejoin: 'stroke-linejoin',
|
|
strokeDasharray: 'stroke-dasharray',
|
|
strokeDashoffset: 'stroke-dashoffset',
|
|
strokeOpacity: 'stroke-opacity',
|
|
strokeMiterlimit: 'stroke-miterlimit',
|
|
fillOpacity: 'fill-opacity',
|
|
fillRule: 'fill-rule',
|
|
clipRule: 'clip-rule',
|
|
clipPath: 'clip-path',
|
|
vectorEffect: 'vector-effect',
|
|
paintOrder: 'paint-order',
|
|
shapeRendering: 'shape-rendering',
|
|
stopColor: 'stop-color',
|
|
stopOpacity: 'stop-opacity',
|
|
}
|
|
|
|
const SKIP_PROPS = new Set(['children', 'key', 'ref', 'dangerouslySetInnerHTML'])
|
|
|
|
type ElementProps = Record<string, unknown> & { children?: ReactNode }
|
|
type ForwardRefComponent = { $$typeof: symbol; render: (props: ElementProps, ref: null) => ReactNode }
|
|
|
|
function escape(value: string | number): string {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
}
|
|
|
|
function serializeProps(props: ElementProps): string {
|
|
let out = ''
|
|
for (const name of Object.keys(props)) {
|
|
if (SKIP_PROPS.has(name)) continue
|
|
const value = props[name]
|
|
if (value === undefined || value === null || value === false) continue
|
|
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'object') continue
|
|
out += ` ${ATTRIBUTE_NAMES[name] ?? name}="${escape(value === true ? '' : (value as string | number))}"`
|
|
}
|
|
return out
|
|
}
|
|
|
|
function serialize(node: unknown): string {
|
|
if (node === null || node === undefined || typeof node === 'boolean') return ''
|
|
if (typeof node === 'string' || typeof node === 'number') return escape(node)
|
|
if (Array.isArray(node)) return node.map(serialize).join('')
|
|
|
|
const element = node as ReactElement<ElementProps>
|
|
const type = element.type as unknown
|
|
const props = (element.props ?? {}) as ElementProps
|
|
|
|
if (typeof type === 'string') {
|
|
return `<${type}${serializeProps(props)}>${serialize(props.children)}</${type}>`
|
|
}
|
|
if (typeof type === 'object' && type !== null && (type as ForwardRefComponent).$$typeof === FORWARD_REF) {
|
|
return serialize((type as ForwardRefComponent).render(props, null))
|
|
}
|
|
if (typeof type === 'function') {
|
|
return serialize((type as (p: ElementProps) => ReactNode)(props))
|
|
}
|
|
throw new Error(`renderIconMarkup: unsupported element type ${String(type)}`)
|
|
}
|
|
|
|
/**
|
|
* Empty string when the element cannot be rendered: a throwing icon component
|
|
* should cost its own glyph, not the marker it sits in. That matches how the
|
|
* call sites already guarded renderToStaticMarkup.
|
|
*/
|
|
export function renderIconMarkup(element: ReactElement): string {
|
|
try {
|
|
return serialize(element)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|