@perspective-dev/viewer 5.5.0 → 5.5.1

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 (44) hide show
  1. package/dist/cdn/perspective-viewer.js +1 -1
  2. package/dist/cdn/perspective-viewer.js.map +1 -1
  3. package/dist/css/monokai.css +1 -1
  4. package/dist/css/themes.css +1 -1
  5. package/dist/css/vaporwave.css +1 -1
  6. package/dist/docs/perspective-docs.json +1 -1
  7. package/dist/esm/perspective-viewer.inline.js +3 -3
  8. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  9. package/dist/esm/perspective-viewer.js +1 -1
  10. package/dist/esm/perspective-viewer.js.map +1 -1
  11. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  12. package/dist/wasm/perspective-viewer.d.ts +27 -16
  13. package/dist/wasm/perspective-viewer.js +112 -101
  14. package/dist/wasm/perspective-viewer.wasm +0 -0
  15. package/dist/wasm/perspective-viewer.wasm.d.ts +16 -16
  16. package/package.json +7 -7
  17. package/src/rust/components/export_dropdown.rs +6 -0
  18. package/src/rust/components/status_bar.rs +12 -3
  19. package/src/rust/components/viewer/filters.rs +2 -2
  20. package/src/rust/components/viewer/msg.rs +4 -0
  21. package/src/rust/components/viewer/panels.rs +3 -3
  22. package/src/rust/components/viewer/wiring.rs +4 -1
  23. package/src/rust/components/viewer.rs +2 -2
  24. package/src/rust/config/export_method.rs +24 -0
  25. package/src/rust/custom_elements/viewer.rs +21 -17
  26. package/src/rust/session/metadata.rs +5 -10
  27. package/src/rust/session.rs +123 -116
  28. package/src/rust/tasks/copy_export.rs +67 -0
  29. package/src/rust/tasks/create_panel.rs +13 -13
  30. package/src/rust/tasks/pipeline.rs +8 -1
  31. package/src/rust/tasks/restore_panel.rs +15 -15
  32. package/src/rust/tasks/sync_update_panels.rs +3 -3
  33. package/src/rust/utils/in_flight.rs +74 -0
  34. package/src/rust/utils/mod.rs +2 -0
  35. package/src/rust/utils/pending_effects.rs +6 -30
  36. package/src/themes/monokai.css +1 -1
  37. package/src/themes/vaporwave.css +1 -1
  38. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  39. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-6813b568219c6763}/inline0.js +0 -0
  40. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-6813b568219c6763}/inline1.js +0 -0
  41. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-6813b568219c6763}/inline2.js +0 -0
  42. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-6813b568219c6763}/inline3.js +0 -0
  43. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-6813b568219c6763}/inline4.js +0 -0
  44. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-6813b568219c6763}/inline5.js +0 -0
@@ -21,7 +21,7 @@ use std::cell::{Cell, Ref, RefCell};
21
21
  use std::collections::{HashMap, HashSet};
22
22
  use std::future::Future;
23
23
  use std::ops::Deref;
24
- use std::rc::Rc;
24
+ use std::rc::{Rc, Weak};
25
25
 
26
26
  use perspective_client::config::*;
27
27
  use perspective_client::proto::ViewDimensionsResp;
@@ -71,12 +71,8 @@ pub struct SessionHandle {
71
71
  pub view_config_changed: PubSub<()>,
72
72
  pub title_changed: PubSub<Option<String>>,
73
73
 
74
- /// Count of in-flight CONFIG-DRIVEN pipeline runs.
75
- in_flight_config_runs: Cell<u32>,
76
-
77
- /// Fires with the ABSOLUTE [`Self::in_flight_config_runs`] count after
78
- /// every change.
79
- pub run_state_changed: PubSub<u32>,
74
+ /// Account of in-flight CONFIG-DRIVEN pipeline runs.
75
+ pub config_runs: InFlight,
80
76
 
81
77
  /// Fires when the user clicks the status indicator while in
82
78
  /// [`StatusIconState::Normal`].
@@ -100,31 +96,22 @@ pub struct SessionHandle {
100
96
  /// copying the config.
101
97
  pub last_dispatched_config: RefCell<Option<std::rc::Rc<crate::config::ViewerConfig>>>,
102
98
 
103
- /// Monotone load counter, bumped once per `load()` call
104
- /// ([`Session::begin_pending_load`]). Identifies the LATEST load so a
105
- /// stale async classification — React binds `load` to a prop and fires it
106
- /// repeatedly, unawaited and out of order — no-ops instead of clobbering a
107
- /// newer load ([`Session::is_current_load`]).
108
- load_generation: Cell<u32>,
109
-
110
99
  /// Open between a `load()` call and its payload's classification as
111
- /// `Table`/`Client` ([`PendingLoad`]).
112
- pending_load: RefCell<Option<PendingLoad>>,
100
+ /// `Table`/`Client` ([`LoadWindow`]), held weakly so its [`LoadGuard`] is
101
+ /// the sole owner.
102
+ pending_load: RefCell<Weak<LoadWindow>>,
113
103
 
114
104
  /// Coalesces `view_config_changed`: multiple synchronous commits in one
115
105
  /// task emit ONE event on the next microtask — the cadence the deleted
116
106
  /// `is_clean` flag provided by accident, now deliberate.
117
107
  config_event_scheduled: Cell<bool>,
118
108
 
119
- /// Count of in-flight `perspective-config-update` dispatch tasks for this
120
- /// panel (see [`Session::track_dispatch`]). `flush()` joins these via
109
+ /// Account of in-flight `perspective-config-update` dispatch tasks for
110
+ /// this panel (see [`Session::track_dispatch`]). `flush()` joins these via
121
111
  /// [`Session::settle_dispatches`], so the "config-update fires before
122
112
  /// `flush()` resolves" contract holds by construction instead of by
123
113
  /// microtask luck.
124
- pending_dispatches: Cell<u32>,
125
-
126
- /// Fires when [`SessionHandle::pending_dispatches`] returns to zero.
127
- dispatches_settled: PubSub<()>,
114
+ dispatches: InFlight,
128
115
 
129
116
  /// Fires when [`SessionHandle::column_stats`] is updated (insert or
130
117
  /// clear). Subscribers re-render and re-query the schema with the
@@ -211,6 +198,11 @@ pub struct SessionData {
211
198
  is_loading: bool,
212
199
  is_paused: bool,
213
200
 
201
+ /// Terminal: set by [`Session::mark_disposed`] when the owning panel is
202
+ /// ejected, never cleared. Distinct from an `Ejected` reset, which a
203
+ /// suspended or element-ejected session may legitimately rebind after.
204
+ disposed: Option<Disposal>,
205
+
214
206
  /// Memo for [`Session::validate_snapshot`]: the expression set validated
215
207
  /// by the last successful server round trip; an equal snapshot skips the
216
208
  /// round trip. Written only under the draw lock; cleared on table
@@ -255,6 +247,19 @@ impl TableErrorState {
255
247
  }
256
248
  }
257
249
 
250
+ /// How a terminal disposal reports itself to a public run still in flight on
251
+ /// the ejected panel.
252
+ #[derive(Clone, Copy, PartialEq, Eq)]
253
+ pub enum Disposal {
254
+ /// A separate call destroyed the panel, so a racing public run rejects
255
+ /// rather than reporting a success it never achieved.
256
+ Reject,
257
+
258
+ /// The ejecting call reports the eviction on its own promise, so a racing
259
+ /// public run resolves rather than surfacing it twice.
260
+ Resolve,
261
+ }
262
+
258
263
  #[derive(Debug, Default)]
259
264
  pub enum TableIntermediateState {
260
265
  #[default]
@@ -262,13 +267,52 @@ pub enum TableIntermediateState {
262
267
  Reloaded,
263
268
  }
264
269
 
265
- /// The open-load window state (see [`SessionHandle::pending_load`]): the
266
- /// generation that opened it (for supersession detection) and the raw config
267
- /// deltas committed while it was open, in commit order, awaiting replay over a
268
- /// reset base if the payload classifies as a `Table`.
269
- pub struct PendingLoad {
270
- generation: u32,
271
- journal: Vec<ViewConfigUpdate>,
270
+ /// The raw config deltas committed while a `load()` classifies its payload, in
271
+ /// commit order, awaiting replay over a reset base if it classifies as a
272
+ /// `Table` — identified by [`Rc`] identity rather than a generation counter.
273
+ #[derive(Default)]
274
+ pub struct LoadWindow {
275
+ journal: RefCell<Vec<ViewConfigUpdate>>,
276
+ }
277
+
278
+ /// A `load()` call site's handle on THE window it opened, closing that window
279
+ /// on `Drop` so no exit can strand `is_loading`.
280
+ pub struct LoadGuard {
281
+ session: Session,
282
+ window: Rc<LoadWindow>,
283
+ }
284
+
285
+ impl LoadGuard {
286
+ /// Close this window and take its journal for replay, or `None` when a
287
+ /// later `load()` superseded it — "abandon this classification, hold the
288
+ /// frame".
289
+ pub fn claim(&self) -> Option<Vec<ViewConfigUpdate>> {
290
+ self.close_current().then(|| self.window.journal.take())
291
+ }
292
+
293
+ /// Close this window, discarding its journal; idempotent, and a no-op once
294
+ /// superseded.
295
+ pub fn close(&self) {
296
+ self.close_current();
297
+ }
298
+
299
+ fn close_current(&self) -> bool {
300
+ let mut slot = self.session.pending_load.borrow_mut();
301
+ if !slot.upgrade().is_some_and(|x| Rc::ptr_eq(&x, &self.window)) {
302
+ return false;
303
+ }
304
+
305
+ *slot = Weak::new();
306
+ drop(slot);
307
+ self.session.borrow_mut().is_loading = false;
308
+ true
309
+ }
310
+ }
311
+
312
+ impl Drop for LoadGuard {
313
+ fn drop(&mut self) {
314
+ self.close();
315
+ }
272
316
  }
273
317
 
274
318
  /// Options for [`Session::reset`]
@@ -293,19 +337,6 @@ pub struct ResetOptions {
293
337
  #[derive(Clone)]
294
338
  pub struct Session(Rc<SessionHandle>);
295
339
 
296
- /// RAII spinner accounting for one config-driven pipeline run.
297
- pub struct ConfigRunToken(Session);
298
-
299
- impl Drop for ConfigRunToken {
300
- fn drop(&mut self) {
301
- let count = self.0.0.in_flight_config_runs.get();
302
- debug_assert!(count > 0, "ConfigRunToken underflow");
303
- let count = count.saturating_sub(1);
304
- self.0.0.in_flight_config_runs.set(count);
305
- self.0.run_state_changed.emit(count);
306
- }
307
- }
308
-
309
340
  impl ImplicitClone for Session {}
310
341
 
311
342
  impl Deref for Session {
@@ -409,21 +440,19 @@ impl Session {
409
440
  }
410
441
  }
411
442
 
412
- /// Open a pending-load window (see [`SessionHandle::pending_load`]) at the
413
- /// `load()` call site. Bumps the load generation and returns it — the
414
- /// caller carries it into the async classification to detect supersession
415
- /// by a later `load()`. Sets `is_loading` so a FRESH panel shows the
416
- /// spinner while a slow payload resolves (a RELOAD keeps its bound table,
417
- /// hence [`TableLoadState::Loaded`], throughout — no spinner flicker).
418
- pub fn begin_pending_load(&self) -> u32 {
419
- let generation = self.0.load_generation.get() + 1;
420
- self.0.load_generation.set(generation);
421
- *self.0.pending_load.borrow_mut() = Some(PendingLoad {
422
- generation,
423
- journal: Vec::new(),
424
- });
443
+ /// Open a load window (see [`LoadWindow`]) at the `load()` call site,
444
+ /// returning the [`LoadGuard`] that owns it. Sets `is_loading` so a FRESH
445
+ /// panel shows the spinner while a slow payload resolves (a RELOAD keeps
446
+ /// its bound table, hence [`TableLoadState::Loaded`], throughout — no
447
+ /// spinner flicker).
448
+ pub fn begin_pending_load(&self) -> Rc<LoadGuard> {
449
+ let window = Rc::new(LoadWindow::default());
450
+ *self.0.pending_load.borrow_mut() = Rc::downgrade(&window);
425
451
  self.borrow_mut().is_loading = true;
426
- generation
452
+ Rc::new(LoadGuard {
453
+ session: self.clone(),
454
+ window,
455
+ })
427
456
  }
428
457
 
429
458
  /// Whether a `load()` payload is still awaiting classification. A `true`
@@ -431,25 +460,23 @@ impl Session {
431
460
  /// ([`crate::tasks::bind_snapshot`]) — the incoming config must never draw
432
461
  /// against the outgoing table.
433
462
  pub fn has_pending_load(&self) -> bool {
434
- self.0.pending_load.borrow().is_some()
435
- }
436
-
437
- /// Close the pending-load window for `generation`, returning its journal
438
- /// (the raw deltas committed while it was open, in order) for replay.
439
- /// Returns `None` when a later `load()` already superseded this one (or it
440
- /// was already closed), which the caller treats as "abandon this
441
- /// classification, hold the frame". Clears `is_loading` — the
442
- /// classification that follows sets the real table state.
443
- pub fn take_pending_load(&self, generation: u32) -> Option<Vec<ViewConfigUpdate>> {
444
- let mut slot = self.0.pending_load.borrow_mut();
445
- if slot.as_ref().map(|p| p.generation) != Some(generation) {
446
- return None;
447
- }
463
+ self.0.pending_load.borrow().strong_count() > 0
464
+ }
448
465
 
449
- let journal = slot.take().map(|p| p.journal);
450
- drop(slot);
451
- self.borrow_mut().is_loading = false;
452
- journal
466
+ /// Mark this session disposed (its panel was ejected): every later
467
+ /// table bind and locked run refuses, so nothing can rebind or draw a
468
+ /// panel that no longer exists.
469
+ pub(crate) fn mark_disposed(&self, disposal: Disposal) {
470
+ self.borrow_mut().disposed = Some(disposal);
471
+ }
472
+
473
+ pub(crate) fn is_disposed(&self) -> bool {
474
+ self.borrow().disposed.is_some()
475
+ }
476
+
477
+ /// This session's disposal, if its panel was ejected.
478
+ pub(crate) fn disposal(&self) -> Option<Disposal> {
479
+ self.borrow().disposed
453
480
  }
454
481
 
455
482
  pub(crate) fn has_table(&self) -> Option<TableLoadState> {
@@ -544,6 +571,10 @@ impl Session {
544
571
  /// Bind an opened `Table`, replacing any previous binding and announcing
545
572
  /// `table_loaded`.
546
573
  pub(crate) async fn bind_table(&self, table: perspective_client::Table) -> ApiResult<()> {
574
+ if self.is_disposed() {
575
+ return Err(ApiError::new("Panel disposed"));
576
+ }
577
+
547
578
  let metadata = SessionMetadata::from_table(&table).await?;
548
579
  let client = table.get_client();
549
580
  let on_error = self.on_table_errored.borrow().clone();
@@ -584,6 +615,10 @@ impl Session {
584
615
  /// Record `name` as pending, dropping any bound `Table` so nothing binds
585
616
  /// against the outgoing table while the name waits for a host.
586
617
  pub(crate) async fn pend_table(&self, name: String) -> ApiResult<()> {
618
+ if self.is_disposed() {
619
+ return Err(ApiError::new("Panel disposed"));
620
+ }
621
+
587
622
  let sub = self.borrow_mut().view_sub.take();
588
623
  self.borrow_mut().table = None;
589
624
  self.borrow_mut().metadata = SessionMetadata::default();
@@ -810,20 +845,20 @@ impl Session {
810
845
  // absent from the outgoing one — mirroring the "no table bound yet"
811
846
  // leniency already in `validate_names`. Authoritative validation is
812
847
  // deferred to the replay.
813
- let pending = self.has_pending_load();
848
+ let window = self.0.pending_load.borrow().upgrade();
814
849
  let mut candidate = self.borrow().config.clone();
815
- let journal_entry = pending.then(|| config_update.clone());
850
+ let journal_entry = window.is_some().then(|| config_update.clone());
816
851
  if !candidate.apply_update(config_update) {
817
852
  return Ok(());
818
853
  }
819
854
 
820
- if let Some(entry) = journal_entry
821
- && let Some(p) = self.0.pending_load.borrow_mut().as_mut()
855
+ if let Some(window) = &window
856
+ && let Some(entry) = journal_entry
822
857
  {
823
- p.journal.push(entry);
858
+ window.journal.borrow_mut().push(entry);
824
859
  }
825
860
 
826
- if !pending {
861
+ if window.is_none() {
827
862
  self.validate_names(&candidate)?;
828
863
  }
829
864
 
@@ -927,19 +962,10 @@ impl Session {
927
962
  /// [`Self::settle_dispatches`]. Used by the `perspective-config-update`
928
963
  /// dispatcher, whose landing `flush()` must be able to await.
929
964
  pub fn track_dispatch(&self, fut: impl std::future::Future<Output = ApiResult<()>> + 'static) {
930
- self.0
931
- .pending_dispatches
932
- .set(self.0.pending_dispatches.get() + 1);
933
- let session = self.clone();
965
+ let guard = self.0.dispatches.guard();
934
966
  ApiFuture::spawn_named("config-update-dispatch", async move {
935
- let result = fut.await;
936
- let remaining = session.0.pending_dispatches.get() - 1;
937
- session.0.pending_dispatches.set(remaining);
938
- if remaining == 0 {
939
- session.0.dispatches_settled.emit(());
940
- }
941
-
942
- if let Err(e) = result {
967
+ let _guard = guard;
968
+ if let Err(e) = fut.await {
943
969
  tracing::error!("[config-update dispatch] {}", e);
944
970
  }
945
971
 
@@ -950,10 +976,7 @@ impl Session {
950
976
  /// Resolve once every in-flight tracked dispatch for this panel has
951
977
  /// landed. Immediate when none are pending.
952
978
  pub async fn settle_dispatches(&self) -> ApiResult<()> {
953
- while self.0.pending_dispatches.get() > 0 {
954
- self.0.dispatches_settled.read_next().await?;
955
- }
956
-
979
+ self.0.dispatches.settle().await;
957
980
  Ok(())
958
981
  }
959
982
 
@@ -973,26 +996,10 @@ impl Session {
973
996
  }
974
997
 
975
998
  /// Begin spinner accounting for ONE config-driven pipeline run: call
976
- /// immediately after the run's commit, and move the returned token INTO
977
- /// the run future. `Drop` settles it on every exit — completion, error,
978
- /// cancellation, and runs that never reach `bind_view` (the
979
- /// deferred-draw restore) — so, unlike edge-counting PubSub pairs, the
980
- /// count cannot strand by construction.
981
- /// [`SessionHandle::run_state_changed`] broadcasts the ABSOLUTE count
982
- /// on both edges.
983
- pub fn begin_config_run(&self) -> ConfigRunToken {
984
- let count = self.0.in_flight_config_runs.get() + 1;
985
- self.0.in_flight_config_runs.set(count);
986
- self.run_state_changed.emit(count);
987
- ConfigRunToken(self.clone())
988
- }
989
-
990
- /// The number of config-driven pipeline runs currently in flight (see
991
- /// [`Self::begin_config_run`]). Read directly when (re)targeting a
992
- /// panel — level-triggered consumers initialize from this and then
993
- /// track [`SessionHandle::run_state_changed`].
994
- pub fn in_flight_config_runs(&self) -> u32 {
995
- self.0.in_flight_config_runs.get()
999
+ /// immediately after the run's commit, and move the returned guard INTO
1000
+ /// the run future.
1001
+ pub fn begin_config_run(&self) -> InFlightGuard {
1002
+ self.0.config_runs.guard()
996
1003
  }
997
1004
 
998
1005
  /// Read the cached `ColumnStats` for a column. Returns `None` if no
@@ -107,6 +107,13 @@ pub async fn txt_as_jsvalue(
107
107
  }
108
108
 
109
109
  /// Generate a result `Blob` for all types of [`ExportMethod`].
110
+ /// The `window` (or a default one) with Arrow IPC body compression `codec`.
111
+ fn compressed(window: Option<ViewWindow>, codec: &str) -> Option<ViewWindow> {
112
+ let mut window = window.unwrap_or_default();
113
+ window.compression = Some(codec.to_owned());
114
+ Some(window)
115
+ }
116
+
110
117
  pub async fn export_method_to_blob(
111
118
  session: &Session,
112
119
  renderer: &Renderer,
@@ -152,6 +159,36 @@ pub async fn export_method_to_blob(
152
159
  ExportMethod::ArrowAll => crate::queries::arrow_as_jsvalue(session, true, None)
153
160
  .await?
154
161
  .as_blob(),
162
+ ExportMethod::ArrowLz4 => {
163
+ crate::queries::arrow_as_jsvalue(session, false, compressed(None, "lz4"))
164
+ .await?
165
+ .as_blob()
166
+ },
167
+ ExportMethod::ArrowLz4Selected => {
168
+ crate::queries::arrow_as_jsvalue(session, false, compressed(viewport, "lz4"))
169
+ .await?
170
+ .as_blob()
171
+ },
172
+ ExportMethod::ArrowLz4All => {
173
+ crate::queries::arrow_as_jsvalue(session, true, compressed(None, "lz4"))
174
+ .await?
175
+ .as_blob()
176
+ },
177
+ ExportMethod::ArrowZstd => {
178
+ crate::queries::arrow_as_jsvalue(session, false, compressed(None, "zstd"))
179
+ .await?
180
+ .as_blob()
181
+ },
182
+ ExportMethod::ArrowZstdSelected => {
183
+ crate::queries::arrow_as_jsvalue(session, false, compressed(viewport, "zstd"))
184
+ .await?
185
+ .as_blob()
186
+ },
187
+ ExportMethod::ArrowZstdAll => {
188
+ crate::queries::arrow_as_jsvalue(session, true, compressed(None, "zstd"))
189
+ .await?
190
+ .as_blob()
191
+ },
155
192
  ExportMethod::Html => html_as_jsvalue(session, renderer, presentation)
156
193
  .await?
157
194
  .as_blob(),
@@ -212,6 +249,36 @@ pub async fn export_method_to_jsvalue(
212
249
  ExportMethod::ArrowAll => crate::queries::arrow_as_jsvalue(session, true, None)
213
250
  .await?
214
251
  .into(),
252
+ ExportMethod::ArrowLz4 => {
253
+ crate::queries::arrow_as_jsvalue(session, false, compressed(None, "lz4"))
254
+ .await?
255
+ .into()
256
+ },
257
+ ExportMethod::ArrowLz4Selected => {
258
+ crate::queries::arrow_as_jsvalue(session, false, compressed(viewport, "lz4"))
259
+ .await?
260
+ .into()
261
+ },
262
+ ExportMethod::ArrowLz4All => {
263
+ crate::queries::arrow_as_jsvalue(session, true, compressed(None, "lz4"))
264
+ .await?
265
+ .into()
266
+ },
267
+ ExportMethod::ArrowZstd => {
268
+ crate::queries::arrow_as_jsvalue(session, false, compressed(None, "zstd"))
269
+ .await?
270
+ .into()
271
+ },
272
+ ExportMethod::ArrowZstdSelected => {
273
+ crate::queries::arrow_as_jsvalue(session, false, compressed(viewport, "zstd"))
274
+ .await?
275
+ .into()
276
+ },
277
+ ExportMethod::ArrowZstdAll => {
278
+ crate::queries::arrow_as_jsvalue(session, true, compressed(None, "zstd"))
279
+ .await?
280
+ .into()
281
+ },
215
282
  ExportMethod::Html => html_as_jsvalue(session, renderer, presentation).await?,
216
283
  ExportMethod::Plugin if renderer.is_chart() => {
217
284
  png_as_jsvalue(session, renderer).await?.into()
@@ -17,7 +17,7 @@ use crate::config::*;
17
17
  use crate::custom_events::wire_panel_events;
18
18
  use crate::presentation::*;
19
19
  use crate::renderer::*;
20
- use crate::session::{MissingTable, ResetOptions, Session};
20
+ use crate::session::{Disposal, MissingTable, ResetOptions, Session};
21
21
  use crate::tasks::*;
22
22
  use crate::utils::*;
23
23
  use crate::workspace::{Panel, PanelId, PanelPhase, Workspace};
@@ -222,19 +222,19 @@ pub(crate) fn place_reserved(
222
222
  /// renderer (slot-scoped plugin + light-DOM cleanup) and eject its table.
223
223
  /// Shared by the root's `ClosePanel` handler and `restoreWorkspace`'s
224
224
  /// batch replacement of the pre-existing panel set.
225
- pub(crate) fn eject_panel(panel: Panel) -> ApiFuture<()> {
226
- let was_errored = panel.session.is_errored();
227
- let dispose_task = panel.renderer.dispose();
228
- let reset_task = panel.session.reset(ResetOptions {
229
- config: true,
230
- expressions: true,
231
- table: Some(session::TableIntermediateState::Ejected),
232
- ..ResetOptions::default()
233
- });
234
-
225
+ pub(crate) fn eject_panel(panel: Panel, disposal: Disposal) -> ApiFuture<()> {
226
+ panel.session.mark_disposed(disposal);
235
227
  ApiFuture::new(async move {
236
- dispose_task.await?;
237
- match reset_task.await.ignore_view_delete() {
228
+ panel.renderer.dispose().await?;
229
+ let was_errored = panel.session.is_errored();
230
+ let reset = panel.session.reset(ResetOptions {
231
+ config: true,
232
+ expressions: true,
233
+ table: Some(session::TableIntermediateState::Ejected),
234
+ ..ResetOptions::default()
235
+ });
236
+
237
+ match reset.await.ignore_view_delete() {
238
238
  Err(_) if was_errored => Ok(()),
239
239
  Err(e) => Err(e),
240
240
  Ok(_) => Ok(()),
@@ -22,7 +22,7 @@ use yew::prelude::*;
22
22
  use crate::config::{ColumnConfigUpdate, PluginConfigUpdate};
23
23
  use crate::presentation::Presentation;
24
24
  use crate::renderer::{RenderContext, Renderer};
25
- use crate::session::{BindDisposition, Session};
25
+ use crate::session::{BindDisposition, Disposal, Session};
26
26
  use crate::utils::RenderGuard;
27
27
 
28
28
  /// Snapshot → validate → bind → cache + pin the [`RenderContext`]. The core
@@ -227,6 +227,13 @@ pub(crate) async fn locked_run(
227
227
  renderer
228
228
  .clone()
229
229
  .render_task(|guard| async move {
230
+ if let Some(disposal) = session.disposal() {
231
+ return match (disposal, spec.origin) {
232
+ (Disposal::Reject, RunOrigin::Public) => Err(ApiError::new("Panel disposed")),
233
+ _ => Ok(()),
234
+ };
235
+ }
236
+
230
237
  renderer.mount_active_plugin()?;
231
238
  if let Some(task) = spec.task {
232
239
  task.await?;
@@ -16,7 +16,9 @@ use perspective_client::utils::PerspectiveResultExt;
16
16
  use crate::config::*;
17
17
  use crate::presentation::Presentation;
18
18
  use crate::renderer::Renderer;
19
- use crate::session::{MissingTable, ResetOptions, Session, TableIntermediateState, probe_table};
19
+ use crate::session::{
20
+ LoadGuard, MissingTable, ResetOptions, Session, TableIntermediateState, probe_table,
21
+ };
20
22
  use crate::tasks::*;
21
23
  use crate::workspace::Workspace;
22
24
  use crate::*;
@@ -88,18 +90,18 @@ pub(crate) async fn bind_table_task(
88
90
  }
89
91
 
90
92
  /// Rebind a session to `name`, probing the incoming table before the
91
- /// outgoing binding is dropped and replaying the journal opened as
92
- /// `generation` over the incoming table's defaults.
93
+ /// outgoing binding is dropped and replaying `load`'s journal over the
94
+ /// incoming table's defaults.
93
95
  async fn rebind_table_task(
94
96
  session: &Session,
95
97
  renderer: &Renderer,
96
98
  workspace: &Workspace,
97
99
  name: String,
98
100
  missing: MissingTable,
99
- generation: u32,
101
+ load: &LoadGuard,
100
102
  ) -> ApiResult<()> {
101
103
  let probed = probe(session, workspace, &name, missing).await;
102
- let journal = session.take_pending_load(generation);
104
+ let journal = load.claim();
103
105
  let (client, table) = probed?;
104
106
  let Some(journal) = journal else {
105
107
  return Ok(());
@@ -186,7 +188,7 @@ pub(crate) async fn restore_panel(
186
188
  session.pending_table(),
187
189
  );
188
190
 
189
- let generation = match &update.table {
191
+ let load = match &update.table {
190
192
  OptionalUpdate::Update(name)
191
193
  if session.is_errored()
192
194
  || (!fresh
@@ -207,18 +209,16 @@ pub(crate) async fn restore_panel(
207
209
  RunOrigin::Public,
208
210
  update.clone(),
209
211
  {
210
- clone!(session, renderer, update.table, workspace);
212
+ clone!(session, renderer, update.table, workspace, load);
211
213
  async move {
212
214
  let OptionalUpdate::Update(name) = table else {
213
215
  return Ok(());
214
216
  };
215
217
 
216
- match generation {
217
- Some(generation) => {
218
- rebind_table_task(
219
- &session, &renderer, &workspace, name, missing, generation,
220
- )
221
- .await
218
+ match &load {
219
+ Some(load) => {
220
+ rebind_table_task(&session, &renderer, &workspace, name, missing, load)
221
+ .await
222
222
  },
223
223
  None => bind_table_task(&session, &workspace, name, missing).await,
224
224
  }
@@ -227,8 +227,8 @@ pub(crate) async fn restore_panel(
227
227
  )
228
228
  .await;
229
229
 
230
- if let Some(generation) = generation {
231
- session.take_pending_load(generation);
230
+ if let Some(load) = &load {
231
+ load.close();
232
232
  }
233
233
 
234
234
  if let Err(e) = &result {
@@ -17,7 +17,7 @@ use wasm_bindgen::prelude::*;
17
17
  use crate::components::viewer::PerspectiveViewerMsg;
18
18
  use crate::config::*;
19
19
  use crate::renderer::*;
20
- use crate::session::Session;
20
+ use crate::session::{Disposal, Session};
21
21
  use crate::tasks::*;
22
22
  use crate::workspace::PanelId;
23
23
  use crate::*;
@@ -91,12 +91,12 @@ pub fn sync_update_panels(
91
91
  }
92
92
 
93
93
  if let Some(panel) = this.workspace.take_reserved() {
94
- eject_tasks.push(eject_panel(panel));
94
+ eject_tasks.push(eject_panel(panel, Disposal::Reject));
95
95
  }
96
96
 
97
97
  for old in &old_ids {
98
98
  if let Some(panel) = this.workspace.remove_panel(old) {
99
- eject_tasks.push(eject_panel(panel));
99
+ eject_tasks.push(eject_panel(panel, Disposal::Reject));
100
100
  }
101
101
  }
102
102
  },