@perspective-dev/viewer 4.5.0 → 4.5.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.
Files changed (118) hide show
  1. package/dist/cdn/perspective-viewer.js +1 -1
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/perspective-viewer.cdn.d.ts +3 -0
  24. package/dist/esm/perspective-viewer.inline.d.ts +3 -0
  25. package/dist/esm/perspective-viewer.inline.js +1 -1
  26. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  27. package/dist/esm/perspective-viewer.js +1 -1
  28. package/dist/esm/perspective-viewer.js.map +2 -2
  29. package/dist/esm/ts-rs/OnUpdateData.d.ts +6 -0
  30. package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +4 -0
  31. package/dist/wasm/perspective-viewer.d.ts +15 -15
  32. package/dist/wasm/perspective-viewer.js +93 -88
  33. package/dist/wasm/perspective-viewer.wasm +0 -0
  34. package/dist/wasm/perspective-viewer.wasm.d.ts +15 -15
  35. package/package.json +1 -1
  36. package/src/{rust/components/style/local_style.rs → css/_column_dropdown.css} +14 -35
  37. package/src/css/_dropdown_menu.css +14 -0
  38. package/src/css/_filter_dropdown.css +14 -0
  39. package/src/css/_function_dropdown.css +14 -0
  40. package/src/css/_viewer_bundle.css +41 -0
  41. package/src/css/column-settings-panel.css +9 -3
  42. package/src/css/column-style.css +9 -1
  43. package/src/css/dom/checkbox.css +2 -2
  44. package/src/css/viewer.css +79 -1
  45. package/src/rust/components/column_dropdown.rs +3 -3
  46. package/src/rust/components/column_selector/active_column.rs +3 -0
  47. package/src/rust/components/column_selector/aggregate_selector.rs +0 -3
  48. package/src/rust/components/column_selector/config_selector.rs +0 -3
  49. package/src/rust/components/column_selector/empty_column.rs +0 -3
  50. package/src/rust/components/column_selector/filter_column.rs +0 -3
  51. package/src/rust/components/column_selector/inactive_column.rs +2 -1
  52. package/src/rust/components/column_selector/invalid_column.rs +0 -4
  53. package/src/rust/components/column_selector.rs +0 -3
  54. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -0
  55. package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs.rs +0 -3
  56. package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +0 -3
  57. package/src/rust/components/column_settings_sidebar.rs +1 -3
  58. package/src/rust/components/containers/dragdrop_list.rs +27 -0
  59. package/src/rust/components/containers/dropdown_menu.rs +1 -3
  60. package/src/rust/components/containers/scroll_panel.rs +1 -3
  61. package/src/rust/components/containers/split_panel.rs +0 -4
  62. package/src/rust/components/containers/tab_list.rs +0 -3
  63. package/src/rust/components/datetime_column_style.rs +0 -3
  64. package/src/rust/components/editable_header.rs +15 -0
  65. package/src/rust/components/empty_row.rs +0 -3
  66. package/src/rust/components/expression_editor.rs +2 -3
  67. package/src/rust/components/filter_dropdown.rs +3 -3
  68. package/src/rust/components/font_loader.rs +2 -2
  69. package/src/rust/components/form/code_editor.rs +0 -3
  70. package/src/rust/components/form/debug.rs +0 -4
  71. package/src/rust/components/function_dropdown.rs +3 -3
  72. package/src/rust/components/number_series_style.rs +0 -3
  73. package/src/rust/components/plugin_selector.rs +0 -3
  74. package/src/rust/components/plugin_tab.rs +0 -5
  75. package/src/rust/components/portal.rs +5 -2
  76. package/src/rust/components/render_warning.rs +0 -3
  77. package/src/rust/components/status_bar.rs +4 -3
  78. package/src/rust/components/string_column_style.rs +0 -3
  79. package/src/rust/components/style/mod.rs +60 -27
  80. package/src/rust/components/style/style_provider.rs +49 -16
  81. package/src/rust/components/style_controls/number_string_format.rs +1 -8
  82. package/src/rust/components/type_icon.rs +1 -4
  83. package/src/rust/components/viewer.rs +79 -5
  84. package/src/rust/custom_elements/copy_dropdown.rs +6 -2
  85. package/src/rust/custom_elements/export_dropdown.rs +6 -2
  86. package/src/rust/lib.rs +0 -1
  87. package/src/rust/renderer.rs +85 -21
  88. package/src/rust/session/metadata.rs +11 -1
  89. package/src/rust/session.rs +14 -1
  90. package/src/rust/tasks/reset_all.rs +38 -18
  91. package/src/rust/tasks/restore_and_render.rs +23 -12
  92. package/src/rust/tasks/send_column_config.rs +3 -2
  93. package/src/rust/tasks/send_plugin_config.rs +3 -2
  94. package/src/rust/tasks/update_and_render.rs +11 -4
  95. package/src/svg/checkbox-checked-icon.svg +1 -1
  96. package/src/svg/checkbox-unchecked-icon.svg +1 -1
  97. package/src/themes/botanical.css +2 -0
  98. package/src/themes/dracula.css +2 -0
  99. package/src/themes/gruvbox-dark.css +2 -0
  100. package/src/themes/icons.css +2 -0
  101. package/src/themes/intl/de.css +1 -0
  102. package/src/themes/intl/es.css +1 -0
  103. package/src/themes/intl/fr.css +1 -0
  104. package/src/themes/intl/ja.css +1 -0
  105. package/src/themes/intl/pt.css +1 -0
  106. package/src/themes/intl/zh.css +1 -0
  107. package/src/themes/intl.css +1 -0
  108. package/src/themes/monokai.css +2 -0
  109. package/src/themes/phosphor.css +2 -0
  110. package/src/themes/pro-dark.css +2 -0
  111. package/src/themes/vaporwave.css +2 -0
  112. package/tsconfig.json +1 -1
  113. package/src/rust/components/style/style_cache.rs +0 -136
  114. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-93064d0d79855d97}/inline0.js +0 -0
  115. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-93064d0d79855d97}/inline1.js +0 -0
  116. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-93064d0d79855d97}/inline2.js +0 -0
  117. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-93064d0d79855d97}/inline3.js +0 -0
  118. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-93064d0d79855d97}/inline4.js +0 -0
@@ -14,9 +14,6 @@ use perspective_client::config::ColumnType;
14
14
  use yew::html::IntoPropValue;
15
15
  use yew::{Properties, classes, function_component, html};
16
16
 
17
- use crate::components::style::LocalStyle;
18
- use crate::css;
19
-
20
17
  #[derive(PartialEq, Debug, Clone, Copy)]
21
18
  pub enum TypeIconType {
22
19
  Type(ColumnType),
@@ -31,7 +28,7 @@ pub struct TypeIconProps {
31
28
  #[function_component(TypeIcon)]
32
29
  pub fn type_icon(p: &TypeIconProps) -> yew::Html {
33
30
  let class = classes!(p.ty.to_string(), "type-icon");
34
- html! { <><LocalStyle href={css!("type-icon")} /><span {class} /></> }
31
+ html! { <><span {class} /></> }
35
32
  }
36
33
 
37
34
  impl From<ColumnType> for TypeIconType {
@@ -14,17 +14,18 @@ use std::rc::Rc;
14
14
 
15
15
  use futures::channel::oneshot::*;
16
16
  use perspective_js::utils::*;
17
+ use wasm_bindgen::JsCast;
17
18
  use wasm_bindgen::prelude::*;
19
+ use web_sys::{FocusEvent, KeyboardEvent};
18
20
  use yew::prelude::*;
19
21
 
20
22
  use super::containers::split_panel::SplitPanel;
21
23
  use super::font_loader::{FontLoader, FontLoaderProps, FontLoaderStatus};
22
- use super::style::{LocalStyle, StyleProvider};
24
+ use super::style::StyleProvider;
23
25
  use crate::components::column_settings_sidebar::ColumnSettingsPanel;
24
26
  use crate::components::main_panel::MainPanel;
25
27
  use crate::components::settings_panel::{SelectedTab, SettingsPanel};
26
28
  use crate::config::*;
27
- use crate::css;
28
29
  use crate::js::JsPerspectiveViewerPlugin;
29
30
  use crate::presentation::{
30
31
  ColumnLocator, ColumnSettingsTab, DragDropProps, Presentation, PresentationProps,
@@ -121,6 +122,70 @@ pub struct PerspectiveViewer {
121
122
  /// Counts in-flight renders (incremented on `view_config_changed`,
122
123
  /// decremented on `view_created`). Threaded to `StatusIndicator`.
123
124
  update_count: u32,
125
+
126
+ /// Window listeners that toggle the `.shift-active` class on the host
127
+ /// element while the Shift key is held, making Shift-modified affordances
128
+ /// (e.g. inactive column add, active column remove, status-bar reset)
129
+ /// visually discoverable. Stored so the closures outlive `create`.
130
+ _shift_listeners: ShiftListeners,
131
+ }
132
+
133
+ struct ShiftListeners {
134
+ elem: web_sys::HtmlElement,
135
+ keydown: Closure<dyn FnMut(KeyboardEvent)>,
136
+ keyup: Closure<dyn FnMut(KeyboardEvent)>,
137
+ blur: Closure<dyn FnMut(FocusEvent)>,
138
+ }
139
+
140
+ impl Drop for ShiftListeners {
141
+ fn drop(&mut self) {
142
+ let win = global::window();
143
+ let _ = win
144
+ .remove_event_listener_with_callback("keydown", self.keydown.as_ref().unchecked_ref());
145
+ let _ =
146
+ win.remove_event_listener_with_callback("keyup", self.keyup.as_ref().unchecked_ref());
147
+ let _ = win.remove_event_listener_with_callback("blur", self.blur.as_ref().unchecked_ref());
148
+ let _ = self.elem.class_list().remove_1("shift-active");
149
+ }
150
+ }
151
+
152
+ fn install_shift_listeners(elem: web_sys::HtmlElement) -> ShiftListeners {
153
+ let keydown = {
154
+ let elem = elem.clone();
155
+ Closure::wrap(Box::new(move |event: KeyboardEvent| {
156
+ if event.key() == "Shift" {
157
+ let _ = elem.class_list().add_1("shift-active");
158
+ }
159
+ }) as Box<dyn FnMut(KeyboardEvent)>)
160
+ };
161
+
162
+ let keyup = {
163
+ let elem = elem.clone();
164
+ Closure::wrap(Box::new(move |event: KeyboardEvent| {
165
+ if event.key() == "Shift" {
166
+ let _ = elem.class_list().remove_1("shift-active");
167
+ }
168
+ }) as Box<dyn FnMut(KeyboardEvent)>)
169
+ };
170
+
171
+ let blur = {
172
+ let elem = elem.clone();
173
+ Closure::wrap(Box::new(move |_: FocusEvent| {
174
+ let _ = elem.class_list().remove_1("shift-active");
175
+ }) as Box<dyn FnMut(FocusEvent)>)
176
+ };
177
+
178
+ let win = global::window();
179
+ let _ = win.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref());
180
+ let _ = win.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref());
181
+ let _ = win.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref());
182
+
183
+ ShiftListeners {
184
+ elem,
185
+ keydown,
186
+ keyup,
187
+ blur,
188
+ }
124
189
  }
125
190
 
126
191
  impl Component for PerspectiveViewer {
@@ -160,6 +225,8 @@ impl Component for PerspectiveViewer {
160
225
  });
161
226
  }
162
227
 
228
+ let shift_listeners = install_shift_listeners(elem);
229
+
163
230
  Self {
164
231
  _subscriptions: subscriptions,
165
232
  column_settings_panel_width_override: None,
@@ -178,6 +245,7 @@ impl Component for PerspectiveViewer {
178
245
  presentation_props,
179
246
  dragdrop_props: DragDropProps::default(),
180
247
  update_count: 0,
248
+ _shift_listeners: shift_listeners,
181
249
  }
182
250
  }
183
251
 
@@ -539,7 +607,6 @@ impl Component for PerspectiveViewer {
539
607
 
540
608
  html! {
541
609
  <StyleProvider root={ctx.props().elem.clone()}>
542
- <LocalStyle href={css!("viewer")} />
543
610
  <div id="component_container">
544
611
  if is_settings_open {
545
612
  <SplitPanel
@@ -548,8 +615,15 @@ impl Component for PerspectiveViewer {
548
615
  skip_empty=true
549
616
  initial_size={self.settings_panel_width_override}
550
617
  on_reset={ctx.link().callback(|_| SettingsPanelSizeUpdate(None))}
551
- on_resize={on_split_panel_resize.clone()}
552
- on_resize_finished={render_callback(&ctx.props().session, &ctx.props().renderer)}
618
+ on_resize={{
619
+ let size_cb = on_split_panel_resize.clone();
620
+ let resize_cb = resize_callback(&ctx.props().session, &ctx.props().renderer);
621
+ move |x| {
622
+ size_cb.emit(x);
623
+ resize_cb.emit(());
624
+ }
625
+ }}
626
+ on_resize_finished={resize_callback(&ctx.props().session, &ctx.props().renderer)}
553
627
  >
554
628
  { settings_panel }
555
629
  <div id="main_column_container">
@@ -22,7 +22,7 @@ use yew::prelude::*;
22
22
  use super::viewer::PerspectiveViewerElement;
23
23
  use crate::components::copy_dropdown::CopyDropDownMenu;
24
24
  use crate::components::portal::PortalModal;
25
- use crate::components::style::StyleProvider;
25
+ use crate::components::style::{StyleProvider, StyleSurface};
26
26
  use crate::config::*;
27
27
  use crate::js::*;
28
28
  use crate::presentation::Presentation;
@@ -77,9 +77,13 @@ impl Component for CopyDropDownWrapper {
77
77
  fn view(&self, ctx: &Context<Self>) -> Html {
78
78
  let on_close = ctx.link().callback(|_| CopyDropDownWrapperMsg::Close);
79
79
  html! {
80
- <StyleProvider root={ctx.props().custom_element.clone()}>
80
+ <StyleProvider
81
+ root={ctx.props().custom_element.clone()}
82
+ surface={StyleSurface::DropdownMenu}
83
+ >
81
84
  <PortalModal
82
85
  tag_name="perspective-copy-menu"
86
+ surface={StyleSurface::DropdownMenu}
83
87
  target={self.target.clone()}
84
88
  own_focus=true
85
89
  {on_close}
@@ -22,7 +22,7 @@ use yew::prelude::*;
22
22
  use super::viewer::PerspectiveViewerElement;
23
23
  use crate::components::export_dropdown::ExportDropDownMenu;
24
24
  use crate::components::portal::PortalModal;
25
- use crate::components::style::StyleProvider;
25
+ use crate::components::style::{StyleProvider, StyleSurface};
26
26
  use crate::config::*;
27
27
  use crate::presentation::Presentation;
28
28
  use crate::renderer::*;
@@ -77,9 +77,13 @@ impl Component for ExportDropDownWrapper {
77
77
  fn view(&self, ctx: &Context<Self>) -> Html {
78
78
  let on_close = ctx.link().callback(|_| ExportDropDownWrapperMsg::Close);
79
79
  html! {
80
- <StyleProvider root={ctx.props().custom_element.clone()}>
80
+ <StyleProvider
81
+ root={ctx.props().custom_element.clone()}
82
+ surface={StyleSurface::DropdownMenu}
83
+ >
81
84
  <PortalModal
82
85
  tag_name="perspective-export-menu"
86
+ surface={StyleSurface::DropdownMenu}
83
87
  target={self.target.clone()}
84
88
  own_focus=true
85
89
  {on_close}
package/src/rust/lib.rs CHANGED
@@ -20,7 +20,6 @@
20
20
  #![feature(stmt_expr_attributes)]
21
21
  #![feature(try_blocks)]
22
22
  #![allow(async_fn_in_trait)]
23
- #![feature(more_qualified_paths)]
24
23
  #![warn(
25
24
  clippy::all,
26
25
  clippy::panic_in_result_fn,
@@ -31,11 +31,10 @@ use std::ops::Deref;
31
31
  use std::pin::Pin;
32
32
  use std::rc::Rc;
33
33
 
34
- use futures::future::select_all;
34
+ use futures::future::{join_all, select_all};
35
35
  use perspective_client::config::ViewConfig;
36
36
  use perspective_client::utils::*;
37
37
  use perspective_client::{View, ViewWindow};
38
- use perspective_js::json;
39
38
  use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt};
40
39
  use serde_json::Value;
41
40
  use wasm_bindgen::prelude::*;
@@ -52,6 +51,7 @@ pub use self::registry::*;
52
51
  use self::render_timer::*;
53
52
  use crate::config::*;
54
53
  use crate::js::plugin::*;
54
+ use crate::queries::resolve_abs_max;
55
55
  use crate::session::Session;
56
56
  use crate::utils::*;
57
57
 
@@ -169,15 +169,6 @@ impl Renderer {
169
169
  }))
170
170
  }
171
171
 
172
- pub async fn reset(&self, columns_config: Option<&ColumnConfigMap>) -> ApiResult<()> {
173
- self.0.borrow_mut().plugins_idx = None;
174
- if let Ok(plugin) = self.get_active_plugin() {
175
- plugin.restore(&json!({}), columns_config)?;
176
- }
177
-
178
- Ok(())
179
- }
180
-
181
172
  pub fn delete(&self) -> ApiResult<()> {
182
173
  self.get_active_plugin().map(|x| x.delete()).unwrap_or_log();
183
174
  self.plugin_data.borrow().viewer_elem.set_inner_text("");
@@ -239,12 +230,69 @@ impl Renderer {
239
230
  /// `include` fields off other fields (e.g. Datagrid's
240
231
  /// `fg_gradient` revealed when `number_fg_mode = "bar"`) will
241
232
  /// reach the plugin without their default value populated.
242
- pub fn all_columns_configs_materialized(
233
+ ///
234
+ /// `async` because per-column stats (e.g. `abs_max` for Datagrid's
235
+ /// `fg_gradient`) may need to be fetched before the schema's
236
+ /// `default` is meaningful. Pass 1 sync-scans the schema for any
237
+ /// column whose `include: true` Number key is missing from its
238
+ /// entry AND has no cached stats — `view_config_changed` clears the
239
+ /// stats cache, so "missing in cache" subsumes "stale". Pass 2
240
+ /// blocks on a parallel `resolve_abs_max` for that set, then runs
241
+ /// the materialize loop with the now-warm cache. Columns never
242
+ /// touched in a stats-dependent mode never trigger a fetch.
243
+ pub async fn all_columns_configs_materialized(
243
244
  &self,
244
245
  view_config: &ViewConfig,
245
246
  session: &Session,
246
247
  ) -> ColumnConfigMap {
247
248
  let mut configs = self.all_columns_configs();
249
+
250
+ // Pass 1: identify columns whose schema demands an `include:
251
+ // true` Number default we don't have stats for.
252
+ let mut to_warm: Vec<String> = vec![];
253
+ for (col, entry) in &configs {
254
+ if session
255
+ .get_column_stats(col)
256
+ .and_then(|s| s.abs_max)
257
+ .is_some()
258
+ {
259
+ continue;
260
+ }
261
+ let Ok(schema) =
262
+ self.query_column_config_schema(view_config, session, col, Some(entry))
263
+ else {
264
+ continue;
265
+ };
266
+ let needs_warm = schema.fields.iter().any(|f| {
267
+ matches!(
268
+ f,
269
+ ControlSpec::Number {
270
+ key,
271
+ include: Some(true),
272
+ ..
273
+ } if !entry.contains_key(key)
274
+ )
275
+ });
276
+ if needs_warm {
277
+ to_warm.push(col.clone());
278
+ }
279
+ }
280
+
281
+ // Block on the (typically tiny) warm set. Clone the metadata
282
+ // and resolve the view ref *before* the .await — `metadata()`
283
+ // returns a live `Ref<>` guard that must not cross an await
284
+ // boundary.
285
+ if !to_warm.is_empty() {
286
+ let metadata = session.metadata().clone();
287
+ let view = session.get_view();
288
+ let futs = to_warm
289
+ .iter()
290
+ .map(|c| resolve_abs_max(session, &metadata, view.as_ref(), c.as_str()));
291
+ join_all(futs).await;
292
+ }
293
+
294
+ // Pass 2: materialize. With stats now in cache, the schema
295
+ // returns a real `default` instead of the placeholder `0`.
248
296
  for (col, entry) in &mut configs {
249
297
  let Ok(schema) =
250
298
  self.query_column_config_schema(view_config, session, col, Some(entry))
@@ -345,6 +393,8 @@ impl Renderer {
345
393
  if let Ok(schema) =
346
394
  self.query_column_config_schema(view_config, session, &col, Some(&cfg))
347
395
  {
396
+ let active = schema.active_keys();
397
+ cfg.retain(|k, _| active.contains(k));
348
398
  strip_default_values(&schema, &mut cfg);
349
399
  }
350
400
 
@@ -468,13 +518,15 @@ impl Renderer {
468
518
  /// the columns-config write paths (strip-on-write) and the
469
519
  /// restore-prep snapshot (materialize-on-read).
470
520
  ///
471
- /// Reads the cached `ColumnStats` (populated by the StyleTab's
472
- /// `fetch_column_abs_max` task and cleared on `view_config_changed`)
521
+ /// Reads the cached `ColumnStats` (cleared on `view_config_changed`)
473
522
  /// so plugins emit gradient defaults against the column's current
474
- /// `abs_max` instead of falling back to `0`. Cache cold ⇒ stats
475
- /// pass through as missing and the plugin uses its `?? 0` fallback;
476
- /// `include: true` fields are still preserved via
477
- /// [`matches_declared_default`]'s short-circuit.
523
+ /// `abs_max` instead of falling back to `0`.
524
+ /// [`Self::all_columns_configs_materialized`] warms the cache on
525
+ /// demand before materializing `include: true` Number fields, so
526
+ /// the restore path always observes a real default; sync callers
527
+ /// (column-config strip-on-write) may still see a missing stats
528
+ /// pass-through and the plugin's `?? 0` fallback, but those writes
529
+ /// re-strip on the next render cycle.
478
530
  fn query_column_config_schema(
479
531
  &self,
480
532
  view_config: &ViewConfig,
@@ -492,6 +544,16 @@ impl Renderer {
492
544
  .and_then(|idx| names.get(idx))
493
545
  .map(|s| s.as_str());
494
546
 
547
+ // If the view schema itself hasn't been built yet (e.g. restore
548
+ // landed before `create_view` populated `view_schema`), refuse
549
+ // to answer rather than returning an empty schema. The strip
550
+ // pass in `update_columns_configs` treats `Err` as "leave the
551
+ // incoming cfg untouched"; an empty schema, by contrast, would
552
+ // cause `cfg.retain(|k, _| active.contains(k))` to drop every
553
+ // key and zero out user-supplied column config.
554
+ if !session.metadata().has_view_schema() {
555
+ return Err(JsValue::from("view_schema not initialized").into());
556
+ }
495
557
  let Some(view_type) = session.metadata().get_column_view_type(column_name) else {
496
558
  return Ok(ColumnConfigSchema { fields: vec![] });
497
559
  };
@@ -549,6 +611,8 @@ impl Renderer {
549
611
  OptionalUpdate::Update(mut map) => {
550
612
  let mut changed = false;
551
613
  if let Some(s) = &schema {
614
+ let active = s.active_keys();
615
+ map.retain(|k, _| active.contains(k));
552
616
  // Default-valued entries in a restore payload
553
617
  // semantically reset the key — strip from the
554
618
  // map AND clear any existing override in the
@@ -986,7 +1050,7 @@ impl Renderer {
986
1050
  }),
987
1051
  ];
988
1052
 
989
- let (x, _, y) = select_all(tasks.into_iter()).await;
1053
+ let (x, _, y) = select_all(tasks).await;
990
1054
  x.ok_or_else(|| y.into_iter().next().unwrap())
991
1055
  }
992
1056
 
@@ -994,8 +1058,8 @@ impl Renderer {
994
1058
  async fn resize_with_explicit_dimensions(&self) -> TaskResult {
995
1059
  let plugin = self.get_active_plugin()?;
996
1060
  let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
997
- let new_width = format!("{}px", &self.0.borrow().viewer_elem.client_width());
998
- let new_height = format!("{}px", &self.0.borrow().viewer_elem.client_height());
1061
+ let new_width = format!("{}px", self.0.borrow().viewer_elem.client_width());
1062
+ let new_height = format!("{}px", self.0.borrow().viewer_elem.client_height());
999
1063
  main_panel.style().set_property("width", &new_width)?;
1000
1064
  main_panel.style().set_property("height", &new_height)?;
1001
1065
  let result = plugin.resize().await;
@@ -95,6 +95,16 @@ impl SessionMetadata {
95
95
  Ok(())
96
96
  }
97
97
 
98
+ /// Whether `view_schema` has been populated by a prior successful
99
+ /// `create_view`. Used by `Session::create_view` to force a full
100
+ /// build on first run even when `is_clean` happens to be true, so
101
+ /// that downstream consumers (`query_column_config_schema`,
102
+ /// strip-on-write in `update_columns_configs`) always see a real
103
+ /// view schema rather than `None`.
104
+ pub fn has_view_schema(&self) -> bool {
105
+ self.as_ref().and_then(|s| s.view_schema.as_ref()).is_some()
106
+ }
107
+
98
108
  pub(super) fn update_expressions(
99
109
  &mut self,
100
110
  valid_recs: &perspective_client::ExprValidationResult,
@@ -302,7 +312,7 @@ impl SessionMetadata {
302
312
  Some(
303
313
  self.get_expression_columns()
304
314
  .cloned()
305
- .chain(self.get_table_columns()?.clone().into_iter())
315
+ .chain(self.get_table_columns()?.clone())
306
316
  .map(move |name| {
307
317
  self.get_column_table_type(&name)
308
318
  .map(|coltype| (name, coltype))
@@ -760,7 +760,20 @@ impl ValidSession<'_> {
760
760
  /// `update()` subscription, consuming this `ValidSession<'_>` and returning
761
761
  /// the original `&Session`.
762
762
  pub async fn create_view(&self) -> Result<Option<View>, ApiError> {
763
- if !self.0.reset_clean() && !self.0.borrow().is_paused {
763
+ // Always consume `is_clean` to preserve the "mark up-to-date"
764
+ // contract of `reset_clean`. Force a full build if no view
765
+ // schema has been built yet — `query_column_config_schema` (and
766
+ // downstream `update_columns_configs`) needs a populated
767
+ // `view_schema` to avoid destructively stripping every key from
768
+ // incoming `columns_config` payloads. Without this fallback, a
769
+ // first restore that lands with `is_clean = true` (e.g., when a
770
+ // prior validate already consumed the flag) would skip the
771
+ // view-schema-building inner block, leaving the schema empty
772
+ // and causing strip-on-write to silently zero out
773
+ // user-supplied column config.
774
+ let was_clean = self.0.reset_clean();
775
+ let needs_schema = !self.0.metadata().has_view_schema();
776
+ if (!was_clean && !self.0.borrow().is_paused) || needs_schema {
764
777
  if !self.1 {
765
778
  let config = self.0.borrow().config.clone();
766
779
  if let Some(sub) = &mut self.0.borrow_mut().view_sub.as_mut() {
@@ -11,13 +11,17 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  //! Cross-engine reset orchestration: reset session config, optionally clear
14
- //! presentation columns config / theme, reset the renderer plugin state, and
15
- //! redraw.
14
+ //! presentation columns config / theme, then delegate to `restore_and_render`
15
+ //! to switch back to the default plugin and redraw.
16
16
 
17
17
  use futures::channel::oneshot;
18
18
  use perspective_client::clone;
19
19
  use perspective_js::utils::ApiFuture;
20
20
 
21
+ use super::restore_and_render;
22
+ use crate::config::{
23
+ ColumnConfigUpdate, OptionalUpdate, PluginConfigUpdate, PluginUpdate, ViewerConfigUpdate,
24
+ };
21
25
  use crate::presentation::Presentation;
22
26
  use crate::renderer::Renderer;
23
27
  use crate::session::{ResetOptions, Session};
@@ -30,6 +34,12 @@ use crate::session::{ResetOptions, Session};
30
34
  ///
31
35
  /// Optionally signals `sender` once the reset+redraw round-trip completes,
32
36
  /// then emits `renderer.reset_changed`.
37
+ ///
38
+ /// Delegates plugin selection + draw to [`restore_and_render`], whose
39
+ /// two-pass restore guarantees the default plugin sees materialized
40
+ /// `columns_config` / `plugin_config` on its first draw — fixing a race
41
+ /// where the raw post-reset bucket would reach the plugin before
42
+ /// stats-dependent `include: true` defaults were resolved.
33
43
  pub fn reset_all(
34
44
  session: &Session,
35
45
  renderer: &Renderer,
@@ -47,32 +57,42 @@ pub fn reset_all(
47
57
  ..ResetOptions::default()
48
58
  })
49
59
  .await?;
50
- let columns_config = if all {
51
- renderer.reset_columns_configs();
52
- renderer.reset_plugin_config();
53
- // Mirror the per-plugin bucket clear on the event bus so
54
- // `PluginTab` re-pulls (its props are interior-mutable
55
- // handles whose identity doesn't change on the reset).
56
- renderer
57
- .plugin_config_changed
58
- .emit(renderer.get_plugin_config());
59
- None
60
- } else {
61
- Some(renderer.all_columns_configs())
62
- };
63
60
 
64
- renderer.reset(columns_config.as_ref()).await?;
65
61
  presentation.reset_available_themes(None).await;
66
62
  if all {
67
63
  presentation.reset_theme().await?;
68
64
  }
69
65
 
70
- let result = renderer.draw(session.validate().await?.create_view()).await;
66
+ // For `all = true`, route the bucket clears through `restore_and_render`'s
67
+ // `update_*` paths as `SetDefault`. This guarantees the materialized
68
+ // restore fires even when the user is already on the default plugin
69
+ // (no plugin_swap signal), since `SetDefault` reports the bucket as
70
+ // `changed` when it was non-empty. The per-plugin bucket model means
71
+ // only the (post-swap) default plugin's bucket is cleared; other
72
+ // plugins' buckets persist with their per-plugin state.
73
+ let (columns_config, plugin_config) = if all {
74
+ (
75
+ ColumnConfigUpdate::SetDefault,
76
+ PluginConfigUpdate::SetDefault,
77
+ )
78
+ } else {
79
+ (OptionalUpdate::Missing, OptionalUpdate::Missing)
80
+ };
81
+
82
+ let update = ViewerConfigUpdate {
83
+ plugin: PluginUpdate::SetDefault,
84
+ plugin_config,
85
+ columns_config,
86
+ ..Default::default()
87
+ };
88
+
89
+ restore_and_render(&session, &renderer, &presentation, update, async { Ok(()) }).await?;
90
+
71
91
  if let Some(sender) = sender {
72
92
  sender.send(()).unwrap();
73
93
  }
74
94
 
75
95
  renderer.reset_changed.emit(());
76
- result
96
+ Ok(())
77
97
  })
78
98
  }
@@ -86,6 +86,22 @@ pub fn restore_and_render(
86
86
  let plugin_swapped = renderer.apply_pending_plugin()?;
87
87
  let plugin = renderer.get_active_plugin()?;
88
88
 
89
+ // The previous call which acquired the lock errored, so skip this render
90
+ if let Some(error) = session.get_error() {
91
+ return Err(error);
92
+ }
93
+
94
+ // Validate + create the view BEFORE applying
95
+ // columns_config / plugin_config updates, so the
96
+ // strip-on-write and materialize passes see fresh
97
+ // `expression_schema` and `view_schema`, and the
98
+ // materialize warm step can call `View::get_min_max`
99
+ // against a view that knows about any new expression
100
+ // columns. Previously this happened after the strip,
101
+ // which silently dropped any `columns_config` entry
102
+ // keyed by a new expression column.
103
+ let view = session.validate().await?.create_view().await?;
104
+
89
105
  // Apply incoming updates into the now-active plugin's
90
106
  // bucket on `Renderer`. Per-plugin storage means no
91
107
  // schema filter is needed before restore — foreign keys
@@ -96,9 +112,9 @@ pub fn restore_and_render(
96
112
  let view_config_snapshot = session.get_view_config().clone();
97
113
  let plugin_config_changed =
98
114
  renderer.update_plugin_config(&view_config_snapshot, plugin_config);
99
-
100
- let changed = plugin_config_changed
101
- || renderer.update_columns_configs(&view_config_snapshot, &session, columns_config);
115
+ let columns_config_changed =
116
+ renderer.update_columns_configs(&view_config_snapshot, &session, columns_config);
117
+ let changed = plugin_config_changed || columns_config_changed;
102
118
 
103
119
  // Force a materialized restore when the plugin just
104
120
  // swapped — `commit_plugin_idx` already restored from the
@@ -109,24 +125,19 @@ pub fn restore_and_render(
109
125
  let plugin_config_snapshot = renderer.get_plugin_config();
110
126
  let plugin_update =
111
127
  wasm_bindgen::JsValue::from_serde_ext(&plugin_config_snapshot).unwrap();
112
- let columns_config =
113
- renderer.all_columns_configs_materialized(&view_config_snapshot, &session);
128
+ let columns_config = renderer
129
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
130
+ .await;
114
131
  plugin.restore(&plugin_update, Some(&columns_config))?;
115
132
  if plugin_config_changed {
116
133
  renderer.plugin_config_changed.emit(plugin_config_snapshot);
117
134
  }
118
135
  }
119
136
 
120
- // The previous call which acquired the lock errored, so skip this render
121
- if let Some(error) = session.get_error() {
122
- return Err(error);
123
- }
124
-
125
- let view = session.validate().await?.create_view().await;
126
137
  if !presentation.is_visible() {
127
138
  Ok(None)
128
139
  } else {
129
- view
140
+ Ok(view)
130
141
  }
131
142
  });
132
143
 
@@ -38,8 +38,9 @@ pub fn send_column_config(
38
38
  clone!(session, renderer);
39
39
  ApiFuture::spawn(async move {
40
40
  let view_config_snapshot = session.get_view_config().clone();
41
- let columns_configs =
42
- renderer.all_columns_configs_materialized(&view_config_snapshot, &session);
41
+ let columns_configs = renderer
42
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
43
+ .await;
43
44
  let plugin_token =
44
45
  wasm_bindgen::JsValue::from_serde_ext(&renderer.get_plugin_config()).unwrap();
45
46
  renderer
@@ -36,8 +36,9 @@ pub fn send_plugin_config(session: &Session, renderer: &Renderer, update: Column
36
36
  let plugin_config = renderer.get_plugin_config();
37
37
  let plugin_token = wasm_bindgen::JsValue::from_serde_ext(&plugin_config).unwrap();
38
38
  let view_config_snapshot = session.get_view_config().clone();
39
- let columns_configs =
40
- renderer.all_columns_configs_materialized(&view_config_snapshot, &session);
39
+ let columns_configs = renderer
40
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
41
+ .await;
41
42
  renderer
42
43
  .get_active_plugin()?
43
44
  .restore(&plugin_token, Some(&columns_configs))?;
@@ -78,6 +78,13 @@ async fn update_and_render_inner(session: Session, renderer: Renderer) -> ApiRes
78
78
  }
79
79
 
80
80
  let plugin_swapped = renderer.apply_pending_plugin()?;
81
+
82
+ // Validate + create the view BEFORE the plugin-swap materialize
83
+ // so the schema query sees fresh `expression_schema` /
84
+ // `view_schema` and `resolve_abs_max` has a bound view that
85
+ // knows about any new expression columns.
86
+ let view = session.validate().await?.create_view().await?;
87
+
81
88
  if plugin_swapped {
82
89
  // `commit_plugin_idx` already restored the new plugin from its
83
90
  // raw bucket; re-run with the materialized snapshot so any
@@ -91,13 +98,13 @@ async fn update_and_render_inner(session: Session, renderer: Renderer) -> ApiRes
91
98
  let view_config_snapshot = session.get_view_config().clone();
92
99
  let plugin_token = wasm_bindgen::JsValue::from_serde_ext(&renderer.get_plugin_config())
93
100
  .unwrap_or(wasm_bindgen::JsValue::NULL);
94
- let columns_config =
95
- renderer.all_columns_configs_materialized(&view_config_snapshot, &session);
101
+ let columns_config = renderer
102
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
103
+ .await;
96
104
  renderer
97
105
  .get_active_plugin()?
98
106
  .restore(&plugin_token, Some(&columns_config))?;
99
107
  }
100
108
 
101
- let view = session.validate().await?;
102
- renderer.draw(view.create_view()).await
109
+ renderer.draw(async { Ok(view) }).await
103
110
  }
@@ -2,6 +2,6 @@
2
2
  <defs></defs>
3
3
  <path
4
4
  d="M 2 0 L 12 0 C 13.105 0 14 0.895 14 2 L 14 12 C 14 13.105 13.105 14 12 14 L 2 14 C 0.895 14 0 13.105 0 12 L 0 2 C 0 0.895 0.895 0 2 0 Z M 3.646 7.354 L 5.646 9.354 L 6 9.707 L 6.354 9.354 L 10.854 4.854 L 10.146 4.146 L 6 8.293 L 4.354 6.646 Z"
5
- fill="#FFFFFF"></path>
5
+ fill="#000000"></path>
6
6
  <path d="M4 7L6 9L10.5 4.5" style="visibility: hidden;"></path>
7
7
  </svg>