Skip to content

feat(layers): show a tiled archive as a group of its source layers - #2065

Draft
clintonlunn wants to merge 2 commits into
opengeos:mainfrom
clintonlunn:feat/layer-style-rules
Draft

feat(layers): show a tiled archive as a group of its source layers#2065
clintonlunn wants to merge 2 commits into
opengeos:mainfrom
clintonlunn:feat/layer-style-rules

Conversation

@clintonlunn

Copy link
Copy Markdown
Contributor

Closes #2062

What

A vector PMTiles archive is added as a folder named after it, with one layer per source layer
inside. The Protomaps basemap arrives as v4 holding water, roads, buildings, earth and the
rest, each in the colour the control assigned it.

Nothing new in the panel: they are ordinary layers in an ordinary group, so visibility, opacity,
reordering, zoom-to, the Style panel and delete all work on them already.

A raster archive, or one with a single source layer, is added as one layer as before.

Why this shape

The first cut gave the layer a list of parts with their own toggles — a second, parallel way to
express visibility and styling. The maintainer's suggestion on #2062 was to use layer groups, which
already carry collapse, group visibility ANDed with each child, group opacity multiplied into each
child, and nesting. That deleted more code than it added and gave per-source-layer styling for free.

The part that needed care

The layers share one MapLibre source. removeLayerFromMap removed a layer's sources unconditionally,
so deleting one source layer would have pulled the source out from under its siblings — with a delete
button now on every row, that is one click away. It now takes the surviving layers and keeps a source
while anything still draws from it.

Colours where an archive is still one layer

The STAC panel and the offline basemap extract build a single layer over all the source layers.
Those still paint each one in the colour the archive assigned (assignedSourceLayerColor), which
was already computed at add time and previously discarded after the first. A user restyling the
layer takes it back.

Tests

pmtiles-archive-layers.test.ts covers the expansion and the refcount, including that a shared
source survives one sibling's removal and goes when the last one does. layer-parts-every-path.test.ts
pins the assigned colours through the vector-tiles and MBTiles sync paths.

6770/6771 pass, tsc clean.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

🔍 Cloudflare PR preview

Item Value
Site https://ed023150.geolibre-preview.pages.dev
Demo app https://ed023150.geolibre-preview.pages.dev/demo/
Commit c60e19c

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

🔍 GitHub Pages PR preview

Item Value
Site https://opengeos.org/pages-preview/GeoLibre/pr-2065/
Demo app https://opengeos.org/pages-preview/GeoLibre/pr-2065/demo/
Commit c60e19c

Note

GitHub Pages built this preview successfully, but its serving edge returned HTTP 403 when checked. The links may still be propagating.

Comment thread packages/map/src/pmtiles-layer.ts Outdated
Comment on lines +148 to +150
nativeLayerIds: options.nativeLayerIds?.filter((id) =>
id.includes(encodeVectorTileLayerPart(sourceLayer)),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: .includes() does substring matching, not exact-segment matching, so two source layers whose encoded names overlap (e.g. water / waterway, or road / railroad) collide. encodeVectorTileLayerPart("waterway") contains encodeVectorTileLayerPart("water") as a substring, so "grid-waterway-fill".includes("water") is true — the water split layer's metadata.nativeLayerIds ends up also containing waterway's native ids.

This isn't just cosmetic: syncExternalNativeLayer's fallback loop (layer-sync.ts ~line 629, reached after ensurePMTilesExternalLayer for a PMTiles vector layer) iterates every id in metadata.nativeLayerIds and applies this layer's visibility, feature filters, zoom range and z-order to whatever native MapLibre layer that id resolves to via map.getLayer. With the over-inclusive list, toggling/reordering the water layer would also mutate waterway's native layer.

Since pmtilesVectorLayerId (same file, used a few lines up and in hasPMTilesNativeSourceLayer) already builds the exact id for a given source layer + kind, matching against that exactly would avoid the collision:

Suggested change
nativeLayerIds: options.nativeLayerIds?.filter((id) =>
id.includes(encodeVectorTileLayerPart(sourceLayer)),
),
nativeLayerIds: options.nativeLayerIds?.filter((id) =>
["fill", "line", "circle"].some(
(kind) => id === pmtilesVectorLayerId(options.id, sourceLayer, kind),
),
),

Confidence: medium-high — traced through getExternalNativeLayerIdssyncExternalNativeLayer's loop, but haven't run it live.

true,
false,
]),
paint: fillExtrusionPaint(layer.style, layer.opacity),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (likely oversight): this still uses layer.style instead of the partStyle computed above (line 3087) for this sourceLayer. Every other paint call in this loop (fill at 3133, line at 3154, circle at 3175) was switched to partStyle, and the analogous fill-extrusion branch in syncMbtilesVectorLayer (line 3242) does use partStyle. As written, a vector-tiles-backed archive layer with extrusionEnabled won't pick up its assigned per-source-layer colour — it'll paint every source layer in the archive's own uniform layer.style colour instead.

Suggested change
paint: fillExtrusionPaint(layer.style, layer.opacity),
paint: fillExtrusionPaint(partStyle, layer.opacity),

Confidence: medium-high — grep across the file shows this is the one Paint(layer.style, …) call left inside a partStyle-scoped loop; no test exercises extrusion + archive colouring together, which would have caught it.

Comment on lines 4811 to 4823
const known = layers.filter((layer) => store.layers.some((item) => item.id === layer.id));
if (known.length > 0) {
for (const layer of known) {
store.updateLayer(layer.id, {
metadata: layer.metadata,
opacity: layer.opacity,
source: layer.source,
style: layer.style,
visible: layer.visible,
});
}
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case: this branches on "at least one of the computed layers is already known", not "all of them are". If a later layeradd event ever reports a different/larger set of source layers for the same event.layerId than an earlier event did (e.g. archive metadata finishes loading asynchronously after a partial first add), known would be a strict subset of layers. This branch would then only update the already-known subset and return — the newly-appeared source layers would never be added to the store, and no group would be created for them.

Not sure this is reachable given how the PMTiles control emits layeradd today, so flagging at lower confidence, but it's worth double-checking whether layerInfo's source-layer set can change across events for the same id, and if so, reconciling known vs layers (add the missing ones) rather than early-returning on partial overlap.

Confidence: low-medium — plausible from reading the store-sync logic, not verified against the control's actual event sequence.

Comment thread packages/map/src/pmtiles-layer.ts Outdated
});
return {
...layer,
metadata: { ...layer.metadata, sourceId: options.id, archiveId: options.id },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: archiveId is set to the same value as sourceId right above it, and nothing in this PR (or elsewhere in the repo, by grep) reads metadata.archiveId. If it's not needed by other code, consider dropping it to avoid a redundant/dead field; if it's meant for something downstream (e.g. future grouping lookups), a short comment on why it's distinct from sourceId would help.

Confidence: low — quality nit, not a correctness issue.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/map/src/pmtiles-layer.ts:148-150createPMTilesArchiveLayers filters a source layer's native ids with id.includes(encodeVectorTileLayerPart(sourceLayer)), which is a substring match, not an exact-segment match. Source layers whose encoded names overlap (e.g. water/waterway) collide, so one split layer's metadata.nativeLayerIds picks up ids that actually belong to a sibling. That over-inclusive list is later iterated in syncExternalNativeLayer's fallback loop (layer-sync.ts ~line 629), which applies this layer's visibility, feature filters, zoom range and z-order to whatever native MapLibre layer each id resolves to — so toggling/reordering one archive layer can silently mutate a sibling with a colliding name. Confidence: medium-high.
  • packages/map/src/layer-sync.ts:3110 — in syncVectorTileLayer's fill-extrusion branch, the paint call still uses layer.style instead of the partStyle computed for the current source layer, while the sibling fill/line/circle calls in the same loop (and the analogous extrusion branch in syncMbtilesVectorLayer at line 3242) correctly use partStyle. A vector-tiles archive layer with extrusion enabled won't get its assigned per-source-layer colour. No test exercises extrusion + archive colouring together. Confidence: medium-high.

Quality

  • packages/plugins/src/plugins/maplibre-components.ts:4811-4823createPMTilesLayerAddHandler branches on "at least one of the computed layers is already known" and, if so, only updates that subset and returns. If a later layeradd event ever reports a larger/different source-layer set than an earlier one for the same id, newly-appeared source layers would never be added or grouped. Not sure it's reachable given the control's current event sequence, so flagging at low-medium confidence.
  • packages/map/src/pmtiles-layer.ts:157metadata.archiveId is set to the same value as metadata.sourceId right above it and isn't read anywhere in this diff or the rest of the repo (by grep); looks like a redundant/dead field unless it's intended for future use. Confidence: low.

Security / Performance / CLAUDE.md

  • No issues found. No injection/unsafe-input surface, no obvious perf regressions (per-sync-pass work stays O(source layers)), and no user-facing strings, catalog files, or mirrored constants from CLAUDE.md's "keep in sync" list are touched by this change.

Comment thread packages/core/src/style-rules.ts Outdated
Comment on lines +1 to +6
// The colours a control gave an archive's source layers.
//
// A control assigns one per source layer and records them all, but a layer carries a single style,
// so every part drew in the first one's colour. This is for archives that are still one layer — the
// STAC panel's, an offline extract. One added through the PMTiles control is split into a layer per
// source layer and never reaches here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module's payoff for "archives that are still one layer" appears unreachable in practice. assignedSourceLayerColor/styleForSourceLayer only do anything when layer.metadata.sourceLayerColors is set, but the only place that ever sets it is pmtilesLayerOptions in packages/plugins/src/plugins/maplibre-components.ts (fed from the PMTiles control's layerInfo.sourceLayerColors) — and that path always goes through createPMTilesArchiveLayers, which splits any archive with 2+ source layers into separate layers rather than keeping it as one.

The two callers this comment names as the "still one layer" beneficiaries — addPMTilesAsset in packages/plugins/src/plugins/stac-layers.ts and the vector branch of BasemapExtractPanel.tsx (~line 707) — call createPMTilesStoreLayer directly and never pass sourceLayerColors. So a multi-source-layer archive added from the STAC panel or the offline basemap extract will still render every source layer in one flat colour, same as before this PR, despite the PR description ("Colours where an archive is still one layer") claiming otherwise.

Worth double-checking against a real STAC/offline-extract archive with several source layers — if I'm right, either those two call sites need to start populating sourceLayerColors, or the PR description/this comment should be corrected. (Confidence: medium-high, based on static analysis — I don't have a way to run the app here.)

Comment on lines 4810 to 4833
// Each layer is added or updated on its own, so a later event reporting a source layer the
// first did not still lands rather than being skipped as "this archive is already here".
const added: string[] = [];
for (const layer of layers) {
if (store.layers.some((item) => item.id === layer.id)) {
store.updateLayer(layer.id, {
metadata: layer.metadata,
opacity: layer.opacity,
source: layer.source,
style: layer.style,
visible: layer.visible,
});
continue;
}
store.addLayer(layer);
added.push(layer.id);
}
// An archive of several source layers is a folder of them, named after the archive.
if (layers.length > 1 && added.length === layers.length) {
store.addLayerGroup(
layerInfo.name || layerNameFromUrl(layerInfo.url, event.layerId),
layers.map((layer) => layer.id),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment on lines 4810-4811 anticipates a later layeradd event reporting a source layer the first event didn't (e.g. metadata that loads incrementally), and the per-layer add/update loop does handle that correctly for the store layer itself. But the group-folding step right below it doesn't: addLayerGroup (in packages/core/src/store.ts) always creates a brand-new group, so it's only called here when added.length === layers.length — i.e. only on the very first event where every split layer is new.

If a later event for the same archive id does add a genuinely new source layer (some already existed and were updated, added.length is between 0 and layers.length), that new layer is pushed into the store but this if is false, so it's never folded into the existing group — it lands as a bare top-level layer instead of inside the archive's folder. This path doesn't appear to be covered by layer-parts-every-path.test.ts or pmtiles-archive-layers.test.ts, both of which construct layers directly rather than driving createPMTilesLayerAddHandler through multiple events.

If the control genuinely never re-fires layeradd with a growing source-layer set for the same id, this is dead code and harmless; if it can (which the comment above suggests was a real concern), this is a real gap. Confidence: medium — I can't inspect the maplibre-gl-components control's emission behavior from here (not installed in this sandbox).

Comment thread packages/map/src/pmtiles-layer.ts Outdated
Comment on lines +155 to +168
return sourceLayers.map((sourceLayer) => {
const assigned = options.sourceLayerColors?.[sourceLayer];
const layer = createPMTilesStoreLayer({
...options,
id: `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`,
name: sourceLayer,
sourceLayers: [sourceLayer],
nativeLayerIds: ownNativeLayerIds(options.nativeLayerIds, options.id, sourceLayer),
...(assigned
? { style: { ...options.style, fillColor: assigned, strokeColor: assigned } }
: {}),
});
return { ...layer, metadata: { ...layer.metadata, sourceId: options.id } };
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: ...options is spread into each split child's createPMTilesStoreLayer call, so every child ends up carrying the entire archive's sourceLayerColors map in its own metadata.sourceLayerColors (e.g. the "water" child also stores the "waterway" colour), not just its own entry. It's harmless today — assignedSourceLayerColor only ever looks up the child's own single source layer — but it's a bit of unnecessary duplication per layer, and a future reader of a single layer's metadata could reasonably be surprised to find colours for other layers embedded in it. Not blocking.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/core/src/style-rules.ts (1-6, mechanism used by packages/map/src/layer-sync.ts): The per-source-layer colouring for "archives that are still one layer" appears unreachable in practice. sourceLayerColors is only ever populated via the PMTiles control's own add-handler in maplibre-components.ts, and that path always splits multi-source-layer archives into separate layers via createPMTilesArchiveLayers. The two flows the PR body cites as beneficiaries — the STAC panel (stac-layers.ts) and the offline basemap extract (BasemapExtractPanel.tsx) — call createPMTilesStoreLayer directly and never pass sourceLayerColors, so multi-source-layer archives added from those two panels still render in one flat colour, unchanged from before this PR. Medium-high confidence (static analysis only).
  • packages/plugins/src/plugins/maplibre-components.ts (4802-4835, createPMTilesLayerAddHandler): the archive-group creation is gated on added.length === layers.length, so it only fires when every split layer is brand new. The adjacent comment explicitly anticipates a later layeradd event reporting a source layer the first one missed, but in that case the newly-added layer would land ungrouped at the top level rather than being folded into the existing archive folder, since addLayerGroup always allocates a fresh group and there's no "add to existing group" path here. Untested. Medium confidence — depends on whether the external control can actually re-fire layeradd with a growing source-layer set for the same id.

Security

  • None found.

Performance

  • None found; the added per-source-layer style lookups are small, bounded loops.

Quality

  • packages/map/src/pmtiles-layer.ts (155-168): each split-out layer's metadata.sourceLayerColors carries the entire archive's colour map (via ...options spread) rather than just its own entry — harmless today since lookups are scoped to the layer's own source layer, but unnecessary duplication and a bit surprising to a future reader. Low severity.

CLAUDE.md

  • No violations noticed; the new code follows existing conventions (no direct MapLibre mutation outside layer-sync, refcounting change is scoped and tested, comments explain non-obvious invariants).

Otherwise the core refactor (splitting a PMTiles archive into a layer group, refcounting the shared source in removeLayerFromMap, and scoping native layer ids per source layer via ownNativeLayerIds) looks correct and is well covered by pmtiles-archive-layers.test.ts and layer-parts-every-path.test.ts.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from 3547866 to 63f6ff2 Compare August 23, 2026 23:46
for (const layer of removedLayers) {
pmtilesControl?.removeLayer(layer.id);
for (const archiveId of pmtilesArchivesFullyRemoved(previous.layers, state.layers)) {
pmtilesControl?.removeLayer(archiveId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium-high confidence): pmtilesArchivesFullyRemoved isn't filtered by controlOwnedArchives, unlike its sibling pmtilesLayerIdsToRemove (which takes an owned set for exactly this reason — see the comment on controlOwnedArchives a few lines above and on isPMTilesControlLayer/pmtilesArchiveId below).

isPMTilesControlLayer now only requires metadata.controlArchiveId to be set, which survives a project save/reload and outlives the control instance that added it. controlOwnedArchives is cleared in teardownPMTilesControl (closing the panel) and rebuilt only from layeradd events on the new control instance.

Concrete sequence: add an archive via the control → close the panel (controlOwnedArchives.clear()) → reopen it (fresh control, controlOwnedArchives still empty for this archive since it was never re-reported via layeradd) → delete one of the archive's layers from the Layers panel. The store subscription still sees a layer whose shape passes isPMTilesControlLayer (it carries controlArchiveId), reports the archive as fully removed, and calls pmtilesControl.removeLayer(archiveId) on a control instance that never loaded that archive — the exact "clear-all takes an archive it never added" failure mode the ownership set was introduced to prevent, just on the store→control direction instead of control→store.

Consider gating the call on ownership too:

Suggested change
pmtilesControl?.removeLayer(archiveId);
pmtilesStoreUnsubscribe ??= useAppStore.subscribe((state, previous) => {
for (const archiveId of pmtilesArchivesFullyRemoved(previous.layers, state.layers)) {
if (!controlOwnedArchives.has(archiveId)) continue;
pmtilesControl?.removeLayer(archiveId);
}
});

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/plugins/src/plugins/maplibre-components.ts:3991pmtilesArchivesFullyRemoved (used in the store→control sync) isn't gated by controlOwnedArchives, unlike pmtilesLayerIdsToRemove (control→store), which explicitly takes an owned set to avoid acting on archives the live control instance never added. Since isPMTilesControlLayer now matches on metadata.controlArchiveId alone (which survives project reloads and outlives the control instance), closing and reopening the PMTiles panel, then deleting a previously-added archive's layer via the Layers panel, calls pmtilesControl.removeLayer(archiveId) on a fresh control that never loaded that archive — the same failure mode controlOwnedArchives was introduced to prevent, just in the other direction. Posted inline with a suggested fix. Confidence: medium-high (the consequence depends on how the third-party maplibre-gl-components control handles removeLayer for an unknown id, which I couldn't verify from source in this environment, but the gap in the guard itself is clear from the code and the PR's own stated intent).

Security

  • None found.

Performance

  • None found. Splitting a many-layer archive (e.g. the Protomaps basemap) into one store layer per source layer adds Zustand/UI-panel entries but not additional MapLibre native layers, so no rendering regression.

Quality

  • Minor, low-confidence: addPMTilesArchive (pmtiles-archive-store.ts) snapshots known once before its add loop; if an archive's vector_layers metadata ever contained duplicate source-layer names, two generated layers would share an id and the second store.addLayer call would run instead of being deduped, since known isn't updated mid-loop. This requires malformed archive metadata to trigger and isn't covered by tests, so I didn't file it as a standalone comment.
  • The PR description's "Colours where an archive is still one layer" section says the STAC panel still builds a single unsplit layer, but stac-layers.ts in this diff now routes STAC assets through createPMTilesArchiveLayers/addPMTilesArchive just like the control — the description appears to be stale from an earlier iteration. Doc-only, no action needed on the code.

CLAUDE.md

  • No violations found — no dependency bumps, external host additions, i18n strings, or mirrored-constant changes are implicated by this diff.

The rest of the change (native-layer-id partitioning for overlapping source-layer names, the source-refcounting in removeLayerFromMap, and the folder create/merge/cleanup logic in pmtiles-archive-store.ts) is well-reasoned and backed by targeted tests that match the code's actual behavior.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from 63f6ff2 to f2ee9c0 Compare August 24, 2026 03:45
Comment on lines +20 to +38
export function addPMTilesArchive(layers: readonly GeoLibreLayer[], name: string): string[] {
const store = useAppStore.getState();
// Taken before the adds, which is what it should be: the ids within one archive are distinct, so
// nothing added here can read back as already known.
const known = new Set(store.layers.map((item) => item.id));
const added: string[] = [];
for (const layer of layers) {
if (known.has(layer.id)) {
store.updateLayer(layer.id, {
metadata: layer.metadata,
opacity: layer.opacity,
source: layer.source,
style: layer.style,
visible: layer.visible,
});
continue;
}
store.addLayer(layer);
added.push(layer.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

known is a snapshot of the store taken once, before the loop, and is never updated as layers are pushed within this same call (comment on line 22-23 assumes "the ids within one archive are distinct, so nothing added here can read back as already known" — but that's not quite guaranteed).

encodeVectorTileLayerPart (packages/map/src/vector-tile-layer-ids.ts) is documented as not injective: a/b and a_2Fb both encode to a_2Fb. createPMTilesArchiveLayers builds each split layer's id as `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`, so an archive with two source layers whose names collide under that encoding produces two GeoLibreLayer objects sharing one id.

Since known doesn't grow during this loop, the second occurrence isn't recognized as "already added in this batch" — it falls to store.addLayer(layer) again. addLayer in packages/core/src/store.ts just appends (layers.push(...)) with no id-uniqueness check, so the store ends up with two distinct layer objects sharing the same id. That breaks the id-uniqueness invariant syncLayers/removeLayerFromMap (keyed Maps/Sets over layer id) and the Layers panel (id as React key) rely on elsewhere.

This is a pre-existing, narrow edge case in the encoder rather than something newly introduced by the encoding itself, but this PR is what first makes two distinct layers derive their id from it in the same batch — previously an archive was always a single layer, so there was nothing to collide with. Low likelihood (needs unusually-named vector-tile layers in the same archive) but worth a guard, e.g. updating known inside the loop:

Suggested change
export function addPMTilesArchive(layers: readonly GeoLibreLayer[], name: string): string[] {
const store = useAppStore.getState();
// Taken before the adds, which is what it should be: the ids within one archive are distinct, so
// nothing added here can read back as already known.
const known = new Set(store.layers.map((item) => item.id));
const added: string[] = [];
for (const layer of layers) {
if (known.has(layer.id)) {
store.updateLayer(layer.id, {
metadata: layer.metadata,
opacity: layer.opacity,
source: layer.source,
style: layer.style,
visible: layer.visible,
});
continue;
}
store.addLayer(layer);
added.push(layer.id);
const added: string[] = [];
for (const layer of layers) {
if (known.has(layer.id)) {
store.updateLayer(layer.id, {
metadata: layer.metadata,
opacity: layer.opacity,
source: layer.source,
style: layer.style,
visible: layer.visible,
});
continue;
}
known.add(layer.id);
store.addLayer(layer);
added.push(layer.id);
}

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • addPMTilesArchive's known set (packages/plugins/src/plugins/pmtiles-archive-store.ts:24-38) is a pre-loop snapshot and never updated as layers are pushed within the same call. Since createPMTilesArchiveLayers derives split-layer ids from encodeVectorTileLayerPart, which is documented as non-injective (a/b and a_2Fb collide), an archive whose source layer names collide under that encoding would produce two layer objects sharing an id, and the second would be pushed via store.addLayer again rather than recognized as a within-batch duplicate — addLayer has no id-uniqueness check, so the store could end up with two entries sharing one id, breaking id-keyed logic elsewhere (sync, removal, panel React keys). Confidence: medium — real gap in the code, but needs an unusual archive to trigger, and it's the first place two distinct layers can derive an id from that known-non-injective encoder in one batch. Posted inline with a small suggested fix.

Security

  • Nothing found. No new user-controlled input paths, no injection surface introduced; archive/source ids are all derived from existing internal ids or a pre-existing non-injective encoder (see above), not raw external strings passed through unsanitized.

Performance

  • pmtilesArchivesFullyRemoved's per-store-update scan (.some() inside a loop over previous) is O(previous × next), but this mirrors the exact complexity of the code it replaced (removedLayers = previous.layers.filter(... !state.layers.some(...))), so it's not a regression introduced by this PR.

Quality

  • Verified the trickier invariants carefully (shared-source refcounting in removeLayerFromMap/getExternalSourceIds, the ownership vs controlArchiveId split for reload/clear-all correctness, ownNativeLayerIds' whole-segment matching against water/waterway, group-folder cleanup timing) — all check out against their tests and the surrounding code. Confidence: high.
  • Minor: the PR description references a test file layer-parts-every-path.test.ts that isn't part of this diff, and describes the STAC panel as still building "a single layer over all the source layers," while the actual diff (stac-layers.ts) now splits STAC assets via createPMTilesArchiveLayers/addPMTilesArchive just like the control. Doesn't affect correctness, but the description is stale relative to the code as shipped. Confidence: high (directly checked against changed-files.txt).

CLAUDE.md

  • No violations spotted; the changed files aren't touching any of the documented mirror-constant/i18n-catalog surfaces called out in CLAUDE.md, and no main-branch or lockfile conventions apply here.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from f2ee9c0 to ad679ee Compare August 24, 2026 04:09
Comment on lines +146 to +167
export function createPMTilesArchiveLayers(options: PMTilesStoreLayerOptions): GeoLibreLayer[] {
const sourceLayers = [...options.sourceLayers];
if (options.tileType === "raster" || sourceLayers.length < 2) {
return [createPMTilesStoreLayer(options)];
}
return sourceLayers.map((sourceLayer) => {
const layer = createPMTilesStoreLayer({
...options,
id: `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`,
name: sourceLayer,
sourceLayers: [sourceLayer],
nativeLayerIds: ownNativeLayerIds(options.nativeLayerIds, options.id, sourceLayer),
});
// Both fields, because readers are split: `getPMTilesSourceId` prefers the metadata,
// `loadedVectorTileFeatures` reads `source.sourceId` alone and swallows a bad id in a `catch`.
return {
...layer,
source: { ...layer.source, sourceId: options.id },
metadata: { ...layer.metadata, sourceId: options.id },
};
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence): createPMTilesArchiveLayers doesn't dedupe sourceLayers before mapping each one to a store layer whose id is `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`. Two entries produce the same store-layer id whenever:

  • the archive's vector_layers metadata literally repeats a source-layer name (malformed/duplicate tileset metadata happens in the wild), or
  • two distinct names collide after encoding — encodeVectorTileLayerPart is documented as "not injective" (a/b and a_2Fb both encode to a_2Fb).

addPMTilesArchive computes its known set once, before iterating, so a colliding second layer isn't recognized as a duplicate: it goes through store.addLayer(layer) again with the same id as the first, silently clobbering/duplicating a layer instead of erroring or merging.

Consider deduping (e.g. new Set(options.sourceLayers)) — or at least logging/dropping repeats — before mapping, since this is now a primary store key rather than just a MapLibre native-layer id suffix (where the pre-existing non-injectivity was lower-stakes).

Suggested change
export function createPMTilesArchiveLayers(options: PMTilesStoreLayerOptions): GeoLibreLayer[] {
const sourceLayers = [...options.sourceLayers];
if (options.tileType === "raster" || sourceLayers.length < 2) {
return [createPMTilesStoreLayer(options)];
}
return sourceLayers.map((sourceLayer) => {
const layer = createPMTilesStoreLayer({
...options,
id: `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`,
name: sourceLayer,
sourceLayers: [sourceLayer],
nativeLayerIds: ownNativeLayerIds(options.nativeLayerIds, options.id, sourceLayer),
});
// Both fields, because readers are split: `getPMTilesSourceId` prefers the metadata,
// `loadedVectorTileFeatures` reads `source.sourceId` alone and swallows a bad id in a `catch`.
return {
...layer,
source: { ...layer.source, sourceId: options.id },
metadata: { ...layer.metadata, sourceId: options.id },
};
});
}
export function createPMTilesArchiveLayers(options: PMTilesStoreLayerOptions): GeoLibreLayer[] {
const sourceLayers = [...new Set(options.sourceLayers)];
if (options.tileType === "raster" || sourceLayers.length < 2) {
return [createPMTilesStoreLayer(options)];
}
return sourceLayers.map((sourceLayer) => {
const layer = createPMTilesStoreLayer({
...options,
id: `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`,
name: sourceLayer,
sourceLayers: [sourceLayer],
nativeLayerIds: ownNativeLayerIds(options.nativeLayerIds, options.id, sourceLayer),
});
// Both fields, because readers are split: `getPMTilesSourceId` prefers the metadata,
// `loadedVectorTileFeatures` reads `source.sourceId` alone and swallows a bad id in a `catch`.
return {
...layer,
source: { ...layer.source, sourceId: options.id },
metadata: { ...layer.metadata, sourceId: options.id },
};
});
}

Comment thread packages/map/src/layer-sync.ts Outdated
Comment on lines +3671 to +3676
// An archive's source layers share one source, so it goes only once nothing draws from it.
const stillInUse = new Set(
(survivingLayers ?? [])
.filter((candidate) => candidate.id !== layerId)
.flatMap((candidate) => getExternalSourceIds(candidate)),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance (low confidence, minor): removeLayerFromMap is called once per removed layer id from the loop in MapController.syncLayers, and each call rebuilds stillInUse from scratch by scanning the entire survivingLayers (next-state) array. For a sync pass that removes many layers at once (bulk delete, project switch tearing down a large layer list), that's O(removed × surviving) work redone every call instead of once. Given typical layer counts this is unlikely to matter, but if it ever does, computing the shared "sources still in use" set once in syncLayers and threading it through (instead of recomputing per call) would avoid the repeated scan.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • createPMTilesArchiveLayers doesn't dedupe sourceLayers before deriving each split layer's store id from `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`. A duplicate or malformed vector_layers entry, or two source-layer names that collide after encodeVectorTileLayerPart's documented non-injective encoding, produce two layers with the same id; addPMTilesArchive's known set is captured once up front, so the second silently clobbers/duplicates the first in the store instead of being recognized as a repeat. Medium confidence — inline comment on packages/map/src/pmtiles-layer.ts with a suggested dedupe fix. (packages/map/src/pmtiles-layer.ts:146-167)

Security

  • None found. No new user input, URL handling, or injection surface introduced by this change.

Performance

  • removeLayerFromMap's new refcount logic (stillInUse) recomputes a full scan of the surviving-layers array on every call, once per removed layer id in a sync pass, rather than once per pass. Likely negligible at normal layer counts, but worth noting for bulk deletes on large projects. Low confidence. (packages/map/src/layer-sync.ts:3671-3676)

Quality

  • No significant issues. The refactor (control-add/remove handlers, ownership tracking via controlOwnedArchives, archive/store-layer separation) is well-factored, the reasoning in the code comments is sound, and I verified the trickier invariants against the surrounding code — the dual source.sourceId/metadata.sourceId write (needed because loadedVectorTileFeatures only reads the former), the per-source-layer native-id partitioning against ensurePMTilesExternalLayer's reuse logic, and the isPMTilesControlLayer/pmtilesArchiveId ownership gating for STAC vs. basemap-extract vs. control-added layers — and didn't find a mismatch.

CLAUDE.md

  • No violations noted; this PR doesn't touch any of the documented mirrored-constant or i18n-catalog areas that CLAUDE.md calls out for special handling.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch 3 times, most recently from ea56cb2 to 7f7d4c9 Compare August 24, 2026 04:42
Comment thread packages/map/src/layer-sync.ts Outdated
Comment on lines +3683 to +3694
const drawnFromSource = (src: string): boolean =>
(map.getStyle()?.layers ?? []).some(
(styleLayer) => "source" in styleLayer && styleLayer.source === src,
);
for (const src of [
...getExternalSourceIds(layer),
sourceId(layerId),
labelSourceId(layerId),
invertedSourceId(layerId),
generatorSourceId(layerId),
]) {
if (src && map.getSource(src)) map.removeSource(src);
if (src && !stillInUse.has(src) && !drawnFromSource(src) && map.getSource(src)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drawnFromSource calls map.getStyle() — which serializes the whole style (sources, layers, sprite, glyphs) — and it's invoked fresh for every source candidate in the loop, not just the PMTiles archive-sharing case. Two compounding issues:

  1. It's evaluated before the cheap map.getSource(src) existence check in the && chain, so it runs even when the candidate source doesn't exist at all (the common case for labelSourceId/invertedSourceId/generatorSourceId on an ordinary layer).
  2. map.getStyle() is re-computed on every iteration of the for (const src of [...]) loop instead of once per removeLayerFromMap call.

Since this runs on every layer removal (not just PMTiles archives — stillInUse is empty whenever survivingLayers isn't passed or doesn't reference the source), this adds up to several full style serializations on every ordinary "delete a layer" action, which previously was just cheap Map/object lookups.

Suggested fix — hoist the style read out of the loop and check existence first:

Suggested change
const drawnFromSource = (src: string): boolean =>
(map.getStyle()?.layers ?? []).some(
(styleLayer) => "source" in styleLayer && styleLayer.source === src,
);
for (const src of [
...getExternalSourceIds(layer),
sourceId(layerId),
labelSourceId(layerId),
invertedSourceId(layerId),
generatorSourceId(layerId),
]) {
if (src && map.getSource(src)) map.removeSource(src);
if (src && !stillInUse.has(src) && !drawnFromSource(src) && map.getSource(src)) {
const styleLayers = map.getStyle()?.layers ?? [];
const drawnFromSource = (src: string): boolean =>
styleLayers.some((styleLayer) => "source" in styleLayer && styleLayer.source === src);
for (const src of [
...getExternalSourceIds(layer),
sourceId(layerId),
labelSourceId(layerId),
invertedSourceId(layerId),
generatorSourceId(layerId),
]) {
if (src && map.getSource(src) && !stillInUse.has(src) && !drawnFromSource(src)) {
map.removeSource(src);
}
}

Confidence: medium-high — correctness is unaffected (the cheap sourceId(layerId) etc. are unique per removed layer, so drawnFromSource almost always resolves false for them anyway), this is purely about avoiding needless getStyle() calls on a hot path.

@@ -1,6 +1,6 @@
import { useAppStore } from "@geolibre/core";
import { createPMTilesStoreLayer, readRemotePMTilesInfo } from "@geolibre/map/pmtiles-layer";
import { createPMTilesArchiveLayers, readRemotePMTilesInfo } from "@geolibre/map/pmtiles-layer";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the function's docstring (a few lines below, unchanged by this diff) still says "the layer shape still comes from {@link createPMTilesStoreLayer}", but addPMTilesAsset now goes through createPMTilesArchiveLayers, which can return several layers (one per source layer) rather than a single one. Worth updating the @link/wording so it doesn't undersell that an asset can now land as multiple layers in a folder.

Confidence: low (doc-only, no behavioral impact).

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • None found. The refcounted-source removal logic (removeLayerFromMap's new survivingLayers param + drawnFromSource), the archive-splitting (createPMTilesArchiveLayers), the control ownership tracking (controlOwnedArchives), and the folder add/remove/grouping logic (addPMTilesArchive, createPMTilesLayerAddHandler/RemoveHandler) all check out against their test coverage and the actual rendering path (ensurePMTilesExternalLayer always derives native layer ids from the naming scheme, so a mismatched metadata.nativeLayerIds never causes a render failure). The color-metadata removal (metadata.sourceLayerColors no longer stored, baked into style.fillColor instead) is intentional and has no stale readers left. Confidence: medium-high, given the scope of the change.

Security

  • None found; no new external input handling, injection surface, or secret handling in this diff.

Performance

  • removeLayerFromMap's new drawnFromSource helper calls map.getStyle() (a full style serialization) for every candidate source on every layer removal — not just the archive-sharing case — and it runs before the cheap map.getSource(src) existence check, so it fires even when the candidate source doesn't exist. This makes ordinary single-layer deletes noticeably more expensive than before. Flagged inline in packages/map/src/layer-sync.ts with a suggested reorder/hoist. Confidence: medium-high.

Quality

  • Stale JSDoc in packages/plugins/src/plugins/stac-layers.ts: still references {@link createPMTilesStoreLayer} even though the function now calls createPMTilesArchiveLayers, which can split an asset into multiple layers. Flagged inline. Confidence: low (doc-only).
  • Minor, not flagged inline (too speculative to be worth a comment): addPMTilesArchive's "existing group" lookup only checks whether any of the archive's current layers still carry a groupId; if a user manually drags every already-added sibling out of its folder in the narrow window before the control's metadata finishes streaming in, a later layeradd event would re-create a fresh folder containing both the new and the previously-ungrouped layers. The PR's own comments suggest this window is narrow (progressive metadata discovery, not later user edits), so this is a low-probability edge case.

CLAUDE.md

  • No violations noticed — no touched mirror-constant, i18n, lockfile, or catalog conventions apply to this change.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch 2 times, most recently from 268e0d3 to 6fcf5a6 Compare August 24, 2026 14:42
Comment on lines +24 to +29
const archiveId = layers[0]?.metadata.sourceId;
if (typeof archiveId === "string") {
for (const stale of store.layers) {
if (stale.metadata.sourceId === archiveId && !ids.has(stale.id)) store.removeLayer(stale.id);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stale-layer cleanup matches purely on stale.metadata.sourceId === archiveId across every layer in the store, not just PMTiles/archive-owned ones — metadata.sourceId is a generic key several other layer kinds set too (see getExternalSourceIds/getLayerSourceIds in layer-sync.ts/map-controller.ts). If an unrelated layer's metadata.sourceId ever collided with a freshly generated archive id, it would be silently removeLayer'd here.

In practice ids are UUID/generator-derived so a real collision is very unlikely, but since this is the one place in the PR that removes layers based on a loosely-typed metadata match rather than an explicit "this is a PMTiles archive layer" check (contrast with isPMTilesControlLayer/pmtilesArchiveId in maplibre-components.ts), it might be worth scoping the stale.metadata.sourceId === archiveId match to layers that are also recognizably PMTiles archive layers (e.g. stale.type === "pmtiles"). Confidence: low — defensive-coding suggestion rather than an observed failure.

@@ -5425,7 +5471,7 @@ export function pmtilesStoreLayer(id: string, layerInfo: PMTilesLayerInfo): GeoL
// The control created these MapLibre layers itself, so its ids stand rather than derived ones.
nativeLayerIds: layerInfo.layerIds,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this comment ("The control created these MapLibre layers itself, so its ids stand rather than derived ones") is only accurate for the single-layer path now. Once createPMTilesArchiveLayers splits an archive into per-source-layer layers, it unconditionally overrides nativeLayerIds: undefined and re-derives them from the naming scheme (see pmtiles-layer.ts), discarding layerInfo.layerIds for every split layer. Worth a one-line update so a future reader doesn't assume the control's own ids always survive.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • No confirmed correctness bugs found in the changed lines. The refcounted shared-source removal in removeLayerFromMap (packages/map/src/layer-sync.ts), the archive-splitting logic in createPMTilesArchiveLayers (packages/map/src/pmtiles-layer.ts), and the control ownership/grouping bookkeeping in maplibre-components.ts/pmtiles-archive-store.ts are internally consistent and well covered by the new tests (including the tricky "delete the whole folder in one pass" and "later read discovers more source layers" cases).

Security

  • None found — no new external input handling, injection surface, or secret handling in this diff.

Performance

  • Medium-low confidence: packages/map/src/headless.ts's createLayerSync calls removeLayerFromMap without the new survivingSourceIds argument (unlike MapController.syncLayers). Its rebuildFrom reorder loop can remove several siblings of a shared PMTiles archive back-to-back within one sync() call, which can transiently drop and immediately recreate the shared source (self-heals same-tick via the subsequent syncLayer pass, but can needlessly evict/refetch archive tiles on reorders). Flagged inline on layer-sync.ts since headless.ts itself wasn't part of the diff.

Quality

  • Low confidence: createPMTilesArchiveLayers's single-layer fallback branch (parts.size < 2) skips the collision-safe dedup the multi-layer branch uses, so a single duplicated source-layer name could produce a layer with duplicated sourceLayers/nativeLayerIds. Harmless today (ensureLayer is idempotent) but inconsistent with the function's stated invariant.
  • Low confidence: addPMTilesArchive's stale-layer cleanup matches on metadata.sourceId === archiveId across all store layers rather than scoping to recognizable PMTiles archive layers — a theoretical (very unlikely) id-collision risk.
  • Nit: a comment in maplibre-components.ts ("the control's ids stand rather than derived ones") is stale for the split-archive path, where ids are always re-derived.

CLAUDE.md

  • No violations found — no touched mirrored constants (MAX_VECTOR_PMTILES_ZOOM, blend-mode selectors, etc.), no i18n-affecting strings, and the PR doesn't touch any of the documented drift-prone areas.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch 2 times, most recently from 8ad326e to 315875d Compare August 24, 2026 16:52
Comment on lines +25 to +32
if (typeof archiveId === "string") {
for (const stale of store.layers) {
// `metadata.sourceId` is a generic key other layer kinds set too, so the type is checked
// rather than trusting an id match to mean "a layer of this archive".
if (stale.type !== "pmtiles" || stale.metadata.sourceId !== archiveId) continue;
if (!ids.has(stale.id)) store.removeLayer(stale.id);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (moderate confidence): this "stale" pass removes layers left over from an old id scheme, but doesn't clean up a now-empty layer group the way createPMTilesLayerRemoveHandler in maplibre-components.ts does for a control-initiated removal.

Trace: archive first arrives with 2 source layers → split into 2 layers + a new folder (line 55-58 below). A later re-add for the same archive reports only 1 distinct id (e.g. the source-layer count shrinks, or tileType flips to raster so createPMTilesArchiveLayers forces the single-layer branch) → this loop removes both old split layers (stale.id not in the new ids) → the grouping block at line 49 is skipped entirely because layers.length is now 1 (layers.length > 1 is false) → the folder that held them is never removed and lingers empty in layerGroups (and gets persisted into the saved project).

Consider tracking the groups of removed stale layers here and pruning any that end up empty, mirroring the cleanup in createPMTilesLayerRemoveHandler:

Suggested change
if (typeof archiveId === "string") {
for (const stale of store.layers) {
// `metadata.sourceId` is a generic key other layer kinds set too, so the type is checked
// rather than trusting an id match to mean "a layer of this archive".
if (stale.type !== "pmtiles" || stale.metadata.sourceId !== archiveId) continue;
if (!ids.has(stale.id)) store.removeLayer(stale.id);
}
}
const archiveId = layers[0]?.metadata.sourceId;
if (typeof archiveId === "string") {
const staleGroupIds = new Set<string>();
for (const stale of store.layers) {
// `metadata.sourceId` is a generic key other layer kinds set too, so the type is checked
// rather than trusting an id match to mean "a layer of this archive".
if (stale.type !== "pmtiles" || stale.metadata.sourceId !== archiveId) continue;
if (ids.has(stale.id)) continue;
if (stale.groupId) staleGroupIds.add(stale.groupId);
store.removeLayer(stale.id);
}
const afterStale = useAppStore.getState();
for (const groupId of staleGroupIds) {
if (!afterStale.layers.some((layer) => layer.groupId === groupId)) {
afterStale.removeLayerGroup(groupId);
}
}
}

Comment on lines 110 to 121
metadata: {
externalNativeLayer: true,
nativeLayerIds: [
...(options.nativeLayerIds ?? pmtilesNativeLayerIds(id, tileType, sourceLayers)),
...(options.nativeLayerIds ?? pmtilesNativeLayerIds(sourceId, tileType, sourceLayers)),
],
pickable: options.pickable ?? true,
sourceId: id,
sourceId,
sourceKind: "pmtiles-url",
...(options.sourceLayerColors ? { sourceLayerColors: options.sourceLayerColors } : {}),
sourceLayers,
tileType,
},
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (medium confidence): this hunk drops the sourceLayerColors field that used to be written into metadata here (removed line: ...(options.sourceLayerColors ? { sourceLayerColors: options.sourceLayerColors } : {})), without replacing its purpose anywhere else.

The PR description says the "archive stays one layer" paths (STAC panel, offline basemap extract) "paint each [source layer] in the colour the archive assigned (assignedSourceLayerColor), which was already computed at add time and previously discarded after the first. A user restyling the layer takes it back," crediting a new test layer-parts-every-path.test.ts.

Neither seems to hold as shipped: fillColor above (line ~81-83) still only ever reads sourceLayerColors[sourceLayers[0]], unchanged from main — so a multi-source-layer archive that stays a single layer (basemap extract, or any STAC asset whose sourceLayers happen to collide/dedupe to one id) still has no way to color source layers past the first, same as before this PR. And layer-parts-every-path.test.ts doesn't exist anywhere in this repo/diff. Worth checking whether the PR body was written against an earlier version of the change — as written it may overstate what this PR actually fixes for per-source-layer coloring.

Comment on lines +132 to +159
export function createPMTilesArchiveLayers(options: PMTilesStoreLayerOptions): GeoLibreLayer[] {
// Keyed by the id each source layer would take, not by its name: `encodeVectorTileLayerPart` is
// not injective (`a/b` and `a_2Fb` both encode to `a_2Fb`), and an archive's metadata can repeat
// a name outright. Either way a second layer would carry the first one's id.
const parts = new Map<string, string>();
for (const sourceLayer of options.sourceLayers) {
const id = `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`;
if (!parts.has(id)) parts.set(id, sourceLayer);
}
if (options.tileType === "raster" || parts.size < 2) {
// Deduped here too, so one layer never carries the same source layer twice.
return [
createPMTilesStoreLayer({ ...options, sourceLayers: [...new Set(options.sourceLayers)] }),
];
}
return [...parts].map(([id, sourceLayer]) =>
createPMTilesStoreLayer({
...options,
id,
name: sourceLayer,
sourceLayers: [sourceLayer],
// The archive's source, and so the archive's ids: a layer deriving its own would name ids
// nothing on the map answers to.
sourceId: options.id,
nativeLayerIds: undefined,
}),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (low confidence, minor): when two source layer names collide under encodeVectorTileLayerPart (or are literal duplicates), the second one is silently dropped — its features never render under any layer, split or unsplit, and nothing surfaces this to the user (confirmed intentional by the pmtiles-archive-layers.test.ts case "the colliding second name is dropped, the first stands"). That's a reasonable fallback for an edge case, but a real archive with two source layers whose data quietly vanishes with no warning/log could be confusing to debug. Worth at least a one-line log/console warning when this happens, if not already planned as follow-up.

@github-actions

Copy link
Copy Markdown
Contributor

All three inline comments posted. Here is the final summary.

Code review

Bugs

  • packages/plugins/src/plugins/pmtiles-archive-store.ts:25-32addPMTilesArchive's stale-layer cleanup (for an archive re-read under a different id scheme) never prunes a layer group that becomes empty as a result, unlike the symmetric cleanup in createPMTilesLayerRemoveHandler. If an archive shrinks from several source layers (grouped) back to one (or flips to raster), the old folder is orphaned in layerGroups and persisted into the saved project. Moderate confidence — traced through the code, not exercised by the new tests (which only cover the grow direction).

Security

  • None found.

Performance

  • None found. The new stillDrawn/getLayersOrder() scan in removeLayerFromMap is memoized per call and only triggered for external-source layers, so it shouldn't add meaningful overhead.

Quality

  • packages/map/src/pmtiles-layer.ts:110-121 — the PR description claims the "archive stays one layer" paths (STAC/basemap-extract) now preserve per-source-layer colors via assignedSourceLayerColor and cites a test layer-parts-every-path.test.ts; neither exists in the diff/repo, and fillColor still only reads the first source layer's color, same as on main. The description appears to describe behavior that wasn't actually shipped. Medium confidence.
  • packages/map/src/pmtiles-layer.ts:132-159 — when two source layer names collide under encodeVectorTileLayerPart (or are literal duplicates), the second is silently dropped with no user-facing warning. Intentional and tested, but worth a log line so it's debuggable. Low confidence, minor.

CLAUDE.md

  • No violations found; the change doesn't touch any of the mirrored-constant/generated-catalog areas called out in CLAUDE.md.

I focused on the new PMTiles-archive splitting/grouping logic, the shared-source refcounting in removeLayerFromMap, and the control add/remove ownership tracking — the rest of the logic (splitting, blend-mode scoping, undo/redo batching, project round-trip) traced correctly against its tests.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from 315875d to 8d0887d Compare August 24, 2026 18:58
Comment on lines +4831 to +4835
// The control no longer has it, so neither does the claim.
const stillListed = new Set(event.state.layers.map((layer) => layer.id));
for (const archiveId of controlOwnedArchives) {
if (!stillListed.has(archiveId)) controlOwnedArchives.delete(archiveId);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drops ownership for any archiveId currently in controlOwnedArchives that isn't in event.state.layers, not just archives touched by this particular removal. If the control ever emits a layerremove event whose state.layers snapshot is momentarily incomplete for an unrelated archive (e.g. a transient event fired while another archive is still being loaded/registered), this would silently forget ownership of a still-live, still-owned archive. A later re-add of that archive would then be treated as never having been the control's own (e.g. pmtilesArchivesFullyRemoved would stop reporting it, and a future clear-all would leave its layers behind).

Since this is about the external control's event-ordering guarantees (which aren't visible from this diff), I'm not fully certain this can happen in practice — flagging as a moderate-confidence concern rather than a confirmed bug.

Comment on lines +60 to +70
if (layers.length > 1 && added.length > 0) {
const state = useAppStore.getState();
const existing = state.layers.find((item) => ids.has(item.id) && item.groupId)?.groupId;
if (existing) {
state.moveLayersToGroup(added, existing);
} else {
state.addLayerGroup(
name,
layers.map((layer) => layer.id),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case: if a later read adds a genuinely new source layer (added.length > 0) while every pre-existing sibling of this archive has been manually moved out of its folder by the user (so none of ids currently carries a groupId), existing resolves to undefined and this falls into addLayerGroup(name, layers.map(l => l.id)) — which passes every id in the archive, including the ones the user deliberately ungrouped, silently re-folding them into a brand-new group. Minor/edge-case UX regression rather than a crash, and only reachable when at least one truly new layer arrives for an archive whose other layers were all manually ungrouped.

Comment on lines +136 to +149
const parts = new Map<string, string>();
for (const sourceLayer of options.sourceLayers) {
const id = `${options.id}-${encodeVectorTileLayerPart(sourceLayer)}`;
const taken = parts.get(id);
if (taken === undefined) {
parts.set(id, sourceLayer);
} else if (taken !== sourceLayer) {
// Two different names, one id: the second's features would draw nowhere. Reported rather
// than dropped in silence — the Diagnostics panel is where a user can see it.
console.warn(
`PMTiles archive "${options.id}": source layer "${sourceLayer}" collides with "${taken}" and is not drawn.`,
);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the collision-detection loop (and its console.warn) runs before the options.tileType === "raster" check below, so a raster archive that happens to report a non-empty/colliding sourceLayers list (raster tiles never split, and the id math is meaningless for them) would still emit a "collides with... and is not drawn" warning even though nothing is actually dropped — the raster branch ignores sourceLayers for splitting purposes entirely. Low impact in practice since readPMTilesArchiveInfo/readRemotePMTilesInfo always return an empty sourceLayers for raster, but worth guarding (if (options.tileType !== "raster") { ...collision loop... }) so the diagnostic can't misfire for a hybrid/unusual archive.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/plugins/src/plugins/maplibre-components.ts (~4831-4835): createPMTilesLayerRemoveHandler clears controlOwnedArchives ownership for any archive id absent from event.state.layers, not just ones this event actually removed — if the control ever emits a layerremove whose snapshot is momentarily incomplete for an unrelated, still-loaded archive, ownership of that archive could be silently forgotten. Moderate confidence (depends on external-control event semantics not visible in this diff).
  • packages/plugins/src/plugins/pmtiles-archive-store.ts (60-70): when a later read adds a genuinely new source layer to an archive whose other layers were all manually moved out of their folder, the fallback path re-groups every layer of the archive (including the ones the user deliberately ungrouped) into a fresh folder. Low/medium confidence, edge case.

Security

  • None found. No user input reaches HTML/DOM/SQL sinks; the new console.warn interpolates archive-supplied strings but only to the console.

Performance

  • None found. The shared-source refcount check (stillDrawn) is lazily memoized per removeLayerFromMap call and only triggers getLayersOrder() for layers that actually declare an external source, so ordinary (non-archive) layer removal is unaffected.

Quality

  • packages/map/src/pmtiles-layer.ts (136-149): the source-layer collision-detection loop (and its warning) runs unconditionally, even for a raster archive, where the per-source-layer id split never applies — low-impact since real raster reads always report empty sourceLayers, but worth guarding for robustness. Low confidence/minor.
  • Minor: the new import in packages/map/src/map-controller.ts (mbtilesStyleLayerIds, externalSourceIdsFor, removeLayerFromMap, ...) isn't alphabetized, unlike the same import list's ordering in layer-sync.ts/headless.ts in this diff. No lint rule enforces import order in this repo, so purely cosmetic.

CLAUDE.md

  • No violations found. The new code consumes only the public, type-checked PMTilesLayerInfo/event surface from maplibre-gl-components rather than reaching into unexported internals, so no new "mirror" constant needs documenting; the new @geolibre/map/pmtiles-layer exports are additions to an already-published subpath entry, requiring no build/export config changes.

Overall the shared-source refcounting logic (externalSourceIdsFor/removeLayerFromMap) and the archive-splitting/grouping logic (createPMTilesArchiveLayers/addPMTilesArchive) are well thought through and heavily tested; I traced the new tests against the implementation and didn't find a case they miss. The three findings above are edge cases around ownership bookkeeping and grouping, not core-path bugs.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from 8d0887d to 3606657 Compare August 24, 2026 23:10
if (stale.type !== "pmtiles" || stale.metadata.sourceId !== archiveId) continue;
if (ids.has(stale.id)) continue;
emptied.add(stale.groupId);
store.removeLayer(stale.id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible reentrancy when an archive migrates id schemes while the control's subscriber is live.

This loop removes the old-scheme layer (e.g. asset-3) via store.removeLayer(stale.id) before the new split layers (asset-3-faults, asset-3-folds) are added a few lines below. When this runs through createPMTilesLayerAddHandler (maplibre-components.ts), the module-level subscriber set up in createPMTilesControl (maplibre-components.ts:3990) fires on this intermediate state transition too — at that point next contains neither the old nor the new layers for the archive, so pmtilesArchivesFullyRemoved reports the archive as fully gone even though it's mid-migration, and the subscriber calls pmtilesControl?.removeLayer(archiveId) back into the third-party control.

That re-entrant call happens while the control is still in the middle of reporting more source layers for that very archive (i.e. inside its own layeradd processing). Depending on what the control's removeLayer does (unregister the archive, abort in-flight requests, drop it from its own list), this could desync the control's panel from the store, or interfere with it continuing to report metadata for the archive — even though controlOwnedArchives.add(event.layerId) at the end of the add handler restores bookkeeping afterward.

This path isn't exercised by any test in this PR — the grouping/removal tests call addPMTilesArchive and the handlers directly, never through the real useAppStore.subscribe wired up in createPMTilesControl, so the interaction between this stale-layer removal and that subscriber isn't covered.

Confidence: medium (the concrete impact depends on the third-party control's removeLayer semantics, which aren't visible from this repo, but the reentrant call itself is real and reproducible from the store/subscriber code alone).

Comment on lines +3686 to +3699
// Only an external source can be shared — the derived ids below are this layer's alone — so the
// map is asked at most once, and only when a shareable source is actually up for removal. Walked
// layer by layer rather than read from `getStyle()`, which serializes the whole document.
const shareable = new Set(getExternalSourceIds(layer));
let drawnSources: Set<string> | undefined;
const stillDrawn = (src: string): boolean => {
drawnSources ??= new Set(
map
.getLayersOrder()
.map((styleLayerId) => map.getLayer(styleLayerId)?.source)
.filter((source): source is string => typeof source === "string"),
);
return drawnSources.has(src);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: this scan now runs for every removal of any layer with an external source, not just shared PMTiles archives.

shareable is populated from getExternalSourceIds(layer), which is non-empty for any layer kind that sets metadata.sourceId/sourceIds — MBTiles, vector-tile, Esri Wayback, 3D Tiles, etc. (many plugins set this). For all of those, stillDrawn now does an O(current style layers) walk (getLayersOrder() + getLayer() per id) on every single removal, even though most of these layer kinds don't actually share a source with siblings — only the new PMTiles-archive split case does.

Removing many such layers in one pass (e.g. deleting a project full of external-source layers, or dispose() in headless.ts) turns what was an O(1)-per-removal source cleanup into effectively O(n²) for n external-source layers, since each removal re-derives drawnSources from scratch (the drawnSources cache is scoped per removeLayerFromMap call, not shared across the batch).

Confidence: medium — likely fine in practice given typical layer counts, but worth confirming this doesn't show up as a regression on large projects/imports with many external layers.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • Possible reentrancy when a control-added PMTiles archive migrates from a single-layer to a split-layer id scheme mid-session: addPMTilesArchive's stale-layer removal (pmtiles-archive-store.ts:33) fires a store transition where the archive briefly has neither its old nor new layers, and the live subscriber in createPMTilesControl (maplibre-components.ts:3990) reads that transient state as "archive fully removed," calling back into the third-party control's removeLayer() while it's still mid-layeradd for that same archive. Untested by this PR's test suite (which exercises addPMTilesArchive/handlers directly, not through the wired-up subscriber). Confidence: medium.

Security

  • None found.

Performance

  • removeLayerFromMap's new stillDrawn refcount check (layer-sync.ts:3686-3699) runs an O(current style layers) scan for every removal of any layer with an external source (MBTiles, vector-tile, Wayback, 3D Tiles, etc.), not just the new shared-source PMTiles archive case, since shareable is derived generically from getExternalSourceIds. Bulk removal of many such layers becomes effectively O(n²). Confidence: medium.

Quality

  • Minor: createPMTilesArchiveLayers's per-source-layer id-collision detection loop runs unconditionally before the tileType === "raster" early-return, so a hypothetical raster archive with overlapping sourceLayers metadata would do pointless collision bookkeeping (harmless today since raster call sites always pass empty sourceLayers). Confidence: low.
  • Minor: in addPMTilesArchive, if an archive's id scheme grows (e.g. 2 → 3 source layers) after a user has manually ungrouped its layers, the "no existing group found" branch will silently recreate a folder around all of them. Narrow edge case. Confidence: low.

CLAUDE.md

  • No violations found — the new createPMTilesArchiveLayers/externalSourceIdsFor exports ride along existing @geolibre/map subpath entries (./pmtiles-layer, internal-only for the latter), so no publishConfig/tsdown drift; no mirrored external-package constants were touched.

Everything else — the refcounted source removal, archive splitting/dedup/collision handling, group lifecycle (create/join/prune), undo coalescing, and project save/reload — is thoroughly covered by the new test files and matches the implementation on inspection.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from 3606657 to e8290c3 Compare August 24, 2026 23:22
} else {
state.addLayerGroup(
name,
layers.map((layer) => layer.id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: when there's no existing group to fold into, this creates a new folder containing every layer in layers (old + new), not just added. The comment right above this (lines 62-64) explicitly describes the intended behavior — "the layer being added now starts a fresh one rather than being pulled back into the folder they emptied" — but the code re-groups everything.

Concretely: an archive is split into layers A and B and grouped. The user manually drags both A and B out of the folder (moveLayersToGroup([...], null)), so neither has a groupId. Later the PMTiles control reports a newly-discovered source layer C for the same archive (progressive metadata discovery is exactly what this module is built to handle). addPMTilesArchive runs again: A and B are known (→ updateLayer, which doesn't touch groupId, so they stay ungrouped), C is new (→ added = [C]). Since no layer among A/B/C currently has a groupId, existing is undefined, and the else branch creates a brand-new folder containing A, B, and C — silently reverting the user's explicit ungrouping of A and B.

Confidence: medium — this requires a specific sequence (split archive → user ungroups all siblings → a later progressive metadata read adds another source layer), but that sequence is exactly the scenario this module's other logic (the stale-shape replacement above) is designed around, and no test covers it.

Suggested change
layers.map((layer) => layer.id),
added,

} else if (taken !== sourceLayer) {
// Two different names, one id: the second's features would draw nowhere. Reported rather
// than dropped in silence — the Diagnostics panel is where a user can see it.
console.warn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor/quality (low confidence): this warns on every call to createPMTilesArchiveLayers. The PMTiles control reports source layers progressively as metadata arrives (per the comments elsewhere in this PR about "a later read" adding source layers), so a genuine encoding collision in an archive will re-log this warning on every progressive layeradd event for that archive, not just once. Not incorrect, just potentially noisy in the console/Diagnostics panel for a long-loading archive.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/plugins/src/plugins/pmtiles-archive-store.ts:71 — When folding a newly-discovered source layer into an existing archive, the "no existing folder" fallback re-groups all of layers (old + new) into a brand-new folder instead of just added. If a user manually ungroups every sibling of a split archive and the control later reports another source layer for it (progressive metadata discovery, which this module is explicitly built to handle), the previously-ungrouped siblings get silently swept back into a new folder — contradicting the adjacent comment's stated intent. Medium confidence; posted inline with a suggested one-line fix (added instead of layers.map((layer) => layer.id)).

Security

  • None found.

Performance

  • removeLayerFromMap's new stillDrawn check walks map.getLayersOrder() fresh for every removed layer that has a shareable source. For a folder delete of a large split archive (e.g. a Protomaps basemap with a couple dozen source layers), that's an O(n) style-layer scan per removed layer, i.e. O(n²) for the whole folder deletion. Likely negligible in practice given typical archive sizes and MapLibre's own layer counts, so low confidence / not flagged inline.

Quality

  • packages/map/src/pmtiles-layer.ts:149 — the source-layer id-collision console.warn re-fires on every call to createPMTilesArchiveLayers, so an archive whose metadata arrives progressively (multiple layeradd events) will log the same collision repeatedly rather than once. Low confidence, posted inline as a minor nit.

CLAUDE.md

  • No violations found — the PR's refactor stays within @geolibre/map/@geolibre/plugins conventions (store-driven layer sync, no direct MapLibre mutation from UI), and the new/changed tests follow the existing node --test style used elsewhere in tests/.

Overall the refcounted-source removal logic (externalSourceIdsFor / removeLayerFromMap) and the archive-splitting/collision-handling in createPMTilesArchiveLayers are carefully reasoned and well covered by the new tests; the one real gap is the grouping-fallback bug above, which isn't exercised by any existing test.

@clintonlunn
clintonlunn force-pushed the feat/layer-style-rules branch from e8290c3 to 3ac6ec9 Compare August 24, 2026 23:41
Comment on lines +154 to +158
if (parts.size < 2) {
// Deduped here too, so one layer never carries the same source layer twice.
return [
createPMTilesStoreLayer({ ...options, sourceLayers: [...new Set(options.sourceLayers)] }),
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (medium confidence): when an archive's source layers collide down to a single id (parts.size < 2) because two different names encode to the same id (not just an exact repeat), this fallback keeps both original names via [...new Set(options.sourceLayers)] instead of the collision-resolved parts map. That reproduces exactly the failure the console.warn above describes — a second, differently-named source layer sharing a derived native-layer id with the first — except here it's silently kept in source.sourceLayers and never actually rendered (the second ensureLayer call for the shared id is a no-op), whereas the split path a few lines down correctly drops the loser.

Example: sourceLayers: ["a/b", "a_2Fb"] — both collide to one id, so parts.size === 1, and this branch returns a single layer covering ["a/b", "a_2Fb"] even though only one of them will ever draw.

Using the already-deduped map instead would keep this path consistent with the split path:

Suggested change
if (parts.size < 2) {
// Deduped here too, so one layer never carries the same source layer twice.
return [
createPMTilesStoreLayer({ ...options, sourceLayers: [...new Set(options.sourceLayers)] }),
];
if (parts.size < 2) {
// Deduped here too, so one layer never carries the same source layer twice.
return [createPMTilesStoreLayer({ ...options, sourceLayers: [...parts.values()] })];
}

Comment thread tests/pmtiles-layer-sync.test.ts Outdated
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { GeoLibreLayer } from "../packages/core/src/types";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: GeoLibreLayer is imported but never used in this file — not caught by CI since the repo's ESLint config only enables the React Hooks rules (no no-unused-vars) and tsc isn't run with noUnusedLocals, but it's dead weight left over from editing.

Suggested change
import type { GeoLibreLayer } from "../packages/core/src/types";

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • packages/map/src/pmtiles-layer.ts:154-158 — when an archive's source layers collide down to exactly one derived id (e.g. two differently-named layers whose encodeVectorTileLayerPart output matches), the unsplit fallback keeps both original names via [...new Set(options.sourceLayers)] instead of the collision-resolved parts map, silently reproducing the "second layer draws nowhere" issue the adjacent console.warn is meant to flag — inconsistent with the split path just below, which correctly drops the loser. Medium confidence; likely rare in real archives.

Security

  • None found.

Performance

  • removeLayerFromMap's new stillDrawn check calls map.getLayersOrder()/map.getLayer() per removal call when an archive folder with many source layers is deleted, giving roughly O(n·m) work (n siblings × m map layers). Bounded by realistic archive/style sizes, so not flagged as a real issue — low confidence this ever matters in practice.

Quality

  • tests/pmtiles-layer-sync.test.ts:3GeoLibreLayer is imported but never used in the file; harmless (no unused-import lint rule is enabled) but worth cleaning up. High confidence, trivial severity.

CLAUDE.md

  • No violations noticed: the PMTiles/MapLibre mirror-constant conventions described in CLAUDE.md aren't touched by this change, and the new code follows the existing comment/documentation style used elsewhere in layer-sync.ts and pmtiles-layer.ts.

Overall the refactor (splitting a tiled archive into one layer per source layer, refcounting the shared MapLibre source on removal, and keeping the PMTiles control's ownership tracking correct across add/remove/reload) is carefully reasoned and well covered by the new tests, including the subtle "never momentarily layerless" and "shared source outlives siblings" cases.

// A sibling's folder, if any sibling is still in one: a user who dragged them all out has said
// this archive is not a folder any more. Where an id was reused, whatever was taken over counts
// as a sibling, so the two archives share a folder under whichever name got there first.
const existing = state.layers.find((item) => ids.has(item.id) && item.groupId)?.groupId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality / edge case (low confidence): existing picks the group of the first layer found in state.layers that belongs to this archive's ids and has a groupId. If a user has manually dragged one sibling out of the archive's folder into a different group (leaving the rest in the original folder), a later re-add (e.g. reopening the panel, or a STAC/control refresh with a changed selection) will fold the newly-added layers into whichever group Array.prototype.find happens to hit first — not necessarily the folder most of the siblings are still in. This is a rare interaction (re-adding an archive whose siblings have been manually split across groups), so the impact is small, but the result is effectively non-deterministic from the user's perspective. Worth a comment noting the "first match wins" behavior is intentional, or picking the most common groupId among the survivors instead.

@github-actions

Copy link
Copy Markdown
Contributor

Code review

Bugs

  • None found with high confidence. I traced the shared-source refcounting in removeLayerFromMap (packages/map/src/layer-sync.ts), the two id schemes reconciled by pmtilesControlLayerId/pmtilesVectorLayerId/pmtilesIdsForSourceLayers (packages/map/src/pmtiles-layer.ts), the source-layer collision resolution in createPMTilesArchiveLayers, and the archive-ownership/ id-reuse handling in pmtiles-archive-store.ts and maplibre-components.ts in detail (including ordering guarantees — own layers removed before the shared-source refcount check, survivingSourceIds computed once per sync pass, teardown clearing ownership before the control's onRemove echo). All of it checked out against the accompanying tests, several of which drive the real maplibre-gl-components package. Confidence: medium-high that this is correct.
  • Minor, low confidence: addPMTilesArchive's "read back after the adds" grouping picks the first archive layer found with a groupId as the folder to reuse (packages/plugins/src/plugins/pmtiles-archive-store.ts:109, inline comment posted). If a user has manually split an archive's layers across two different groups, a later re-add non-deterministically picks whichever one Array.prototype.find hits first rather than the majority group. Rare interaction, small impact.

Security

  • No injection, unsafe input handling, or secrets issues found. console.warn calls interpolate archive URLs/ids into log strings only (no HTML rendering, no eval), and all new state is either module-local (bounded by the plugin's lifetime) or Zustand store data.

Performance

  • The identity check state.layers === previous.layers added to the store subscription (maplibre-components.ts) is a genuine improvement — it now skips the archive-removal scan on unrelated store writes (e.g. mousemove-driven state), where the old code re-filtered previous.layers on every store change.
  • No new O(n²) or unbounded-growth concerns beyond what's already documented as an intentional tradeoff (the never-cleared reportedCollisions/reportedSourceIdClashes warning-dedup sets, and programmaticPMTilesAdds), which are small and self-limiting in practice.

Quality

  • Exceptionally thorough inline documentation and test coverage for a change with this much subtlety (id-scheme mirroring, refcounted shared sources, archive-ownership races). pmtiles-control-contract.test.ts in particular drives a real PMTilesLayerControl against a synthesized archive rather than a mock, which meaningfully de-risks the "mirrors an unexported id template" pattern this codebase relies on elsewhere.
  • See the one inline comment above (arbitrary group pick on a rare re-add-after-manual-split scenario).

CLAUDE.md

  • The new bullet documenting pmtilesControlLayerId/pmtilesIdsForSourceLayers/pmtilesIdNamesSourceLayer as a mirror of maplibre-gl-components internals follows the file's established pattern for these mirrors, correctly names the guarding test (tests/pmtiles-control-contract.test.ts), and matches the actual implementation. No issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Show a tiled archive's sublayers and toggle them individually

1 participant