Fix zoom property crash after deleting a stop (#2153)

## Launch Checklist

<!-- Thanks for the PR! Feel free to add or remove items from the
checklist. -->

When a fixed paint value is converted to a zoom function, Maputnik
creates exactly two stops.

<img width="362" height="226" alt="image"
src="https://github.com/user-attachments/assets/209aebbf-7bb3-4422-b0c7-1019e20a9ddb"
/>

Deleting either one of those two stops leaves a single remaining value.
During the transition back to the ordinary field editor, `ZoomProperty`
can render with `stops` undefined. It previously called `.map`
unconditionally, causing the editor to crash.

<img width="2551" height="1248" alt="image"
src="https://github.com/user-attachments/assets/f5c2af59-dcae-4ffe-8245-f175eea9c843"
/>

This PR guards the stop list with optional chaining. The change only
protects the render path and does not alter zoom-function conversion or
value persistence.

Now, the remaining stop's value will be restored after deleting the
other stop:

<img width="367" height="168" alt="image"
src="https://github.com/user-attachments/assets/8d13236b-6140-451b-8613-0bc5f11893de"
/>

The regression test covers `circle-opacity`:

1. Convert a fixed value to a zoom function.
2. Change the second stop's value.
3. Delete the first of the two stops.
4. Verify that the ordinary field editor reappears.
5. Verify that the remaining stop's value is restored.
6. Edit the restored field and verify that the style updates
successfully.

 - [x] Briefly describe the changes in this PR.
 - [ ] Link to related issues.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.
This commit is contained in:
liuly
2026-09-09 23:04:32 +08:00
committed by GitHub
parent 1f475cf715
commit 80c0d08179
3 changed files with 24 additions and 13 deletions
+2
View File
@@ -8,6 +8,8 @@
- _...Add new stuff here..._
### 🐞 Bug fixes
- Prevent a crash when converting a function to an expression
- Prevent a crash when deleting either stop from a function with two stops
- Preserve expanded layer groups when deleting layers, including the first layer of a group
- The map's data listener now fires on tile loads again, so source and vector layer field autocompletion is populated
- The `maputnik` desktop binary now opens the default browser automatically on startup (opt out with `--no-browser`)
+16 -1
View File
@@ -289,6 +289,20 @@ describe("layer editor", () => {
});
});
test("should leave a plain editable value after deleting a stop", async () => {
await when.setFunctionStopValue("circle-radius", "Output value", 1, "0");
await when.deleteFunctionStop("circle-radius");
await then(get.elementByTestId("spec-field:circle-radius")).shouldBeVisible();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": 0 } }],
});
await when.setValue("spec-field-input:circle-radius", "7");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": 7 } }],
});
});
test("should set the base", async () => {
await when.setFunctionBase("circle-radius", "2");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -310,8 +324,9 @@ describe("layer editor", () => {
});
});
test("should convert to an expression", async () => {
test("should convert to an expression without crashing", async () => {
await when.makeExpression("circle-radius");
await then(get.element("[data-wd-key='spec-field-container:circle-radius'] .maputnik-expression-editor")).shouldBeVisible();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }],
});
+6 -12
View File
@@ -129,16 +129,10 @@ type FieldFunctionProps = {
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
*/
export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const [dataType, setDataType] = React.useState(
getDataType(props.value, props.fieldSpec)
);
const [isEditing, setIsEditing] = React.useState(false);
React.useEffect(() => {
if (!isEditing) {
setDataType(getDataType(props.value, props.fieldSpec));
}
}, [props.value, props.fieldSpec, isEditing]);
// Keep the expression editor mounted while typing, but otherwise select the
// editor from the current value so a collapsed function never renders as stops.
const dataType = isEditing ? "expression" : getDataType(props.value, props.fieldSpec);
const getFieldFunctionType = (fieldSpec: any) => {
if (fieldSpec.expression.interpolated) {
@@ -173,7 +167,7 @@ export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const deleteExpression = () => {
const { fieldSpec, fieldName } = props;
props.onChange(fieldName, fieldSpec.default);
setDataType("value");
setIsEditing(false);
};
const deleteStop = (stopIdx: number) => {
@@ -233,10 +227,10 @@ export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
type: "identity",
property: value[1],
});
setDataType("value");
setIsEditing(false);
} else if (isLiteralExpression(value)) {
props.onChange(fieldName, value[1]);
setDataType("value");
setIsEditing(false);
}
};