@nanobpm/urban 0.40.1 → 0.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1084,6 +1084,19 @@ function renderDataGrid(node) {
1084
1084
  try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
1085
1085
  }
1086
1086
  }
1087
+ // Capture scroll offsets of scrolled descendants before the swap. An open detail node is
1088
+ // reused (same element instance), but replaceChildren() detaches it, which resets the
1089
+ // scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,
1090
+ // max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached
1091
+ // by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)
1092
+ // rather than O(grid size). Restoration below skips any node that isn't re-attached.
1093
+ const scrollSaved = [];
1094
+ for (const entry of detailNodes.values()) {
1095
+ if (!entry.built) continue;
1096
+ for (const sc of entry.dtr.querySelectorAll("*")) {
1097
+ if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);
1098
+ }
1099
+ }
1087
1100
  tbody.replaceChildren();
1088
1101
  for (const row of rows) renderRow(row);
1089
1102
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
@@ -1095,6 +1108,10 @@ function renderDataGrid(node) {
1095
1108
  try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
1096
1109
  }
1097
1110
  }
1111
+ // Restore scroll depth on the reused nodes that survived the swap (still connected).
1112
+ for (const [sc, top, left] of scrollSaved) {
1113
+ if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }
1114
+ }
1098
1115
  } catch (e) {
1099
1116
  tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
1100
1117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.40.1",
3
+ "version": "0.40.2",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -234,6 +234,21 @@ test("renderer keeps the detail input focused across the refresh poll", async ()
234
234
  assert.match(js, /s\.addRange\(savedRange\)/);
235
235
  });
236
236
 
237
+ test("renderer preserves inner scroll depth across the refresh poll", async () => {
238
+ // Regression: an open detail row is reused across the 5s poll, but tbody.replaceChildren()
239
+ // detaches it, which resets the scrollTop of any inner overflow:auto container — so a
240
+ // scrolled-into transcript (.pc-transcript, max-height + overflow:auto) jumped back to the
241
+ // top every refreshMs. The client now captures scroll offsets of scrolled descendants
242
+ // before the swap and restores them on the nodes that stay connected afterwards.
243
+ const res = await dispatch("GET", "/app/runtime.js");
244
+ const js = res.body ?? "";
245
+ // Offsets are captured from scrolled descendants before replaceChildren()…
246
+ assert.match(js, /const scrollSaved = \[\]/);
247
+ assert.match(js, /if\s*\(\s*sc\.scrollTop\s*\|\|\s*sc\.scrollLeft\s*\)\s*scrollSaved\.push/);
248
+ // …and restored only on nodes re-attached by the rebuild (still connected).
249
+ assert.match(js, /if\s*\(\s*sc\.isConnected\s*\)\s*\{\s*sc\.scrollTop\s*=\s*top\s*;\s*sc\.scrollLeft\s*=\s*left\s*;\s*\}/);
250
+ });
251
+
237
252
  test("renderer wires a column's linkField to a new-tab anchor", async () => {
238
253
  const res = await dispatch("GET", "/app/runtime.js");
239
254
  const js = res.body ?? "";
@@ -1132,6 +1132,19 @@ function renderDataGrid(node) {
1132
1132
  try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
1133
1133
  }
1134
1134
  }
1135
+ // Capture scroll offsets of scrolled descendants before the swap. An open detail node is
1136
+ // reused (same element instance), but replaceChildren() detaches it, which resets the
1137
+ // scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,
1138
+ // max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached
1139
+ // by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)
1140
+ // rather than O(grid size). Restoration below skips any node that isn't re-attached.
1141
+ const scrollSaved = [];
1142
+ for (const entry of detailNodes.values()) {
1143
+ if (!entry.built) continue;
1144
+ for (const sc of entry.dtr.querySelectorAll("*")) {
1145
+ if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);
1146
+ }
1147
+ }
1135
1148
  tbody.replaceChildren();
1136
1149
  for (const row of rows) renderRow(row);
1137
1150
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
@@ -1143,6 +1156,10 @@ function renderDataGrid(node) {
1143
1156
  try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
1144
1157
  }
1145
1158
  }
1159
+ // Restore scroll depth on the reused nodes that survived the swap (still connected).
1160
+ for (const [sc, top, left] of scrollSaved) {
1161
+ if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }
1162
+ }
1146
1163
  } catch (e) {
1147
1164
  tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
1148
1165
  }