vue2-excel-grid 2.0.0 → 2.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vue2-excel-grid",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
4
4
  "description": "Excel-style grid component for Vue 2.6.14 with virtual scrolling",
5
5
  "license": "MIT",
6
6
  "author": "Fozilbek Raimberdiyev",
@@ -947,8 +947,8 @@ import EgLocalSyncModal from './EgLocalSyncModal.vue'
947
947
  import { parseDateValue, formatDate } from '../utils/dateUtils.js'
948
948
  import { formatNumber as _formatNumber } from '../utils/numberFormat.js'
949
949
  import { Mask, MaskInput } from '../utils/mask.js'
950
- import { listVersions, getVersion, deleteVersion, saveOrTouchVersion } from '../utils/idbStore.js'
951
- import { hashRowsAsync } from '../utils/dataHash.js'
950
+ import { listVersions, getVersion, deleteVersion, saveOrTouchVersion, putBuffer, getBuffer, clearBuffer } from '../utils/idbStore.js'
951
+ import { hashRows, hashRowsAsync } from '../utils/dataHash.js'
952
952
 
953
953
  const SIZE_CONFIG = {
954
954
  small: { row: 22, header: 24, footer: 24, filter: 26 },
@@ -2402,7 +2402,20 @@ const ExcelGridComponent = {
2402
2402
  naturalGridWidth() {
2403
2403
  if (this.widthMode === 'auto') this.$nextTick(this.syncHeaderViewportWidth)
2404
2404
  },
2405
- widthMode() { this.$nextTick(this.syncHeaderViewportWidth) }
2405
+ widthMode() { this.$nextTick(this.syncHeaderViewportWidth) },
2406
+
2407
+ // localSyncKey feature — mounted() only calls _loadLocalVersionsOnMount()
2408
+ // once, at mount time. Without this watcher, a key that starts empty (a
2409
+ // host conditionally passing `:local-sync-key="ready ? id : null"`, or
2410
+ // one only known after an async load) and later becomes truthy would
2411
+ // never load its version list, never promote a leftover buffer, and
2412
+ // never show the toolbar badge — the feature would look "broken" for the
2413
+ // rest of that page's lifetime even though everything else works.
2414
+ localSyncKey(newKey, oldKey) {
2415
+ if (newKey === oldKey) return
2416
+ this.localSyncAvailable = false
2417
+ if (newKey) this._loadLocalVersionsOnMount()
2418
+ }
2406
2419
  },
2407
2420
 
2408
2421
  created() {
@@ -2526,6 +2539,7 @@ const ExcelGridComponent = {
2526
2539
  clearTimeout(this._onInputTimer)
2527
2540
  clearTimeout(this._dragCleanupTimer)
2528
2541
  clearTimeout(this._widthSyncChainResetTimer)
2542
+ clearTimeout(this._bufferWriteTimer)
2529
2543
  if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)
2530
2544
  document.removeEventListener('mousemove', this.onGlobalMouseMove)
2531
2545
  document.removeEventListener('mouseup', this.onGlobalMouseUp)
@@ -2537,7 +2551,10 @@ const ExcelGridComponent = {
2537
2551
  window.removeEventListener('pagehide', this._onPageHideForLocalSync)
2538
2552
  // Best-effort — the component may be torn down (SPA route change) without
2539
2553
  // the page itself unloading, which visibilitychange/pagehide won't catch.
2540
- this._flushLocalVersionOnHide()
2554
+ // sync: true — see _flushLocalVersionOnHide()'s comment; a component
2555
+ // being destroyed is exactly the same "might not survive an async hop"
2556
+ // situation as the other two triggers.
2557
+ this._flushLocalVersionOnHide(false, true)
2541
2558
  if (this.isFullscreen) {
2542
2559
  document.body.style.overflow = this._prevBodyOverflow || ''
2543
2560
  document.removeEventListener('keydown', this._onFullscreenKeyDown)
@@ -6055,6 +6072,7 @@ const ExcelGridComponent = {
6055
6072
  // All edit paths already use spread ({ ...row }) so frozen rows are safe.
6056
6073
  const data = result.data.map(row => Object.freeze(row))
6057
6074
  this.$emit('input', data)
6075
+ this._scheduleBufferWrite(data)
6058
6076
  this.$emit('update:columns', columns)
6059
6077
 
6060
6078
  const hasMerges = merges && merges.length > 0
@@ -6170,6 +6188,7 @@ const ExcelGridComponent = {
6170
6188
  }
6171
6189
  }
6172
6190
  this.$emit('input', newData)
6191
+ this._scheduleBufferWrite(newData)
6173
6192
  },
6174
6193
 
6175
6194
  // Emit 'update:merges' and record a merges diff entry.
@@ -6203,6 +6222,7 @@ const ExcelGridComponent = {
6203
6222
  newData.length = entry.before.rowCount
6204
6223
  }
6205
6224
  this.$emit('input', newData)
6225
+ this._scheduleBufferWrite(newData)
6206
6226
  } else if (entry.type === 'merges') {
6207
6227
  this.internalMerges = entry.before.slice()
6208
6228
  this.$emit('update:merges', entry.before.slice())
@@ -6225,6 +6245,7 @@ const ExcelGridComponent = {
6225
6245
  newData[entry.after.indices[i]] = entry.after.rows[i]
6226
6246
  }
6227
6247
  this.$emit('input', newData)
6248
+ this._scheduleBufferWrite(newData)
6228
6249
  } else if (entry.type === 'merges') {
6229
6250
  this.internalMerges = entry.after.slice()
6230
6251
  this.$emit('update:merges', entry.after.slice())
@@ -6267,6 +6288,7 @@ const ExcelGridComponent = {
6267
6288
  // already exist for this key (e.g. saved in an earlier visit).
6268
6289
  async _loadLocalVersionsOnMount() {
6269
6290
  if (!this.localSyncKey) return
6291
+ await this._promoteBufferIfAny()
6270
6292
  try {
6271
6293
  const versions = await listVersions(this.localSyncKey)
6272
6294
  if (!versions.length) return
@@ -6281,12 +6303,76 @@ const ExcelGridComponent = {
6281
6303
  }
6282
6304
  },
6283
6305
 
6306
+ // Debounced (800ms), called from the grid's own real-change points
6307
+ // (_emitDataChange, import, undo, redo — the exact same "user worked"
6308
+ // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,
6309
+ // for the same reason spelled out on the localSyncKey prop's JSDoc: a
6310
+ // host loading rows from an API must never look like a user edit).
6311
+ //
6312
+ // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no
6313
+ // hash, no history) at a moment with no teardown race at all — the user
6314
+ // is actively still on the page. This is what actually fixes reload/
6315
+ // close data loss: a version-store write ATTEMPTED at the leave-signal
6316
+ // has no guarantee of finishing before an actual page teardown (verified
6317
+ // directly — even a trivial, unrelated IndexedDB write failed to survive
6318
+ // a real page.reload() in Chromium), but a buffer write that already
6319
+ // completed minutes or seconds earlier, during ordinary editing, needs no
6320
+ // such guarantee — it's already durably there. _promoteBufferIfAny()
6321
+ // turns it into a real version next time the grid mounts.
6322
+ _scheduleBufferWrite(data) {
6323
+ if (!this.localSyncKey) return
6324
+ const key = this.localSyncKey // captured now, not re-read 800ms later —
6325
+ // a host that swaps localSyncKey mid-edit (rare, but now reactive, see
6326
+ // the localSyncKey watcher above) must not have this land under the
6327
+ // NEW key with the OLD key's data.
6328
+ clearTimeout(this._bufferWriteTimer)
6329
+ this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(key, data) }, 800)
6330
+ },
6331
+
6332
+ async _writeBufferNow(key, data) {
6333
+ if (!key) return
6334
+ try {
6335
+ await putBuffer(key, data)
6336
+ } catch (error) {
6337
+ this.$emit('local-sync-error', { action: 'save', error })
6338
+ }
6339
+ },
6340
+
6341
+ // Called once from _loadLocalVersionsOnMount(), before this key's version
6342
+ // list is read — folds a leftover buffer (from an edit that was saved
6343
+ // during the PREVIOUS visit but never made it into 'versions' — the
6344
+ // reload/close case this whole mechanism exists for) into a real version.
6345
+ // No time pressure here, so this uses the Worker-backed hashRowsAsync()
6346
+ // like any other non-leave-signal path.
6347
+ async _promoteBufferIfAny() {
6348
+ try {
6349
+ const buffered = await getBuffer(this.localSyncKey)
6350
+ if (!buffered) return
6351
+ const hash = await hashRowsAsync(buffered.data)
6352
+ const { version, overwritten } = await saveOrTouchVersion(
6353
+ this.localSyncKey, buffered.data, hash, this.localSyncMaxVersions
6354
+ )
6355
+ await clearBuffer(this.localSyncKey)
6356
+ this.$emit('local-sync-save', {
6357
+ key: this.localSyncKey,
6358
+ id: version.id,
6359
+ rows: buffered.data.length,
6360
+ updatedAt: version.updatedAt,
6361
+ overwritten
6362
+ })
6363
+ } catch (error) {
6364
+ this.$emit('local-sync-error', { action: 'save', error })
6365
+ }
6366
+ },
6367
+
6368
+ // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the
6369
+ // passive leave-triggers can't afford a Worker round-trip.
6284
6370
  _onVisibilityChangeForLocalSync() {
6285
- if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide()
6371
+ if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)
6286
6372
  },
6287
6373
 
6288
6374
  _onPageHideForLocalSync() {
6289
- this._flushLocalVersionOnHide()
6375
+ this._flushLocalVersionOnHide(false, true)
6290
6376
  },
6291
6377
 
6292
6378
  // The core save-on-leave path — replaces a per-edit autosave (see the
@@ -6296,34 +6382,60 @@ const ExcelGridComponent = {
6296
6382
  // does the latter). Triggered from mounted()'s visibilitychange/pagehide
6297
6383
  // listeners and beforeDestroy() — see those for the three trigger points.
6298
6384
  //
6299
- // Content-hashes the current `data` (via a Web Worker when available —
6300
- // src/utils/dataHash.js — falling back to the same algorithm
6301
- // synchronously otherwise, so this always resolves) and either touches
6302
- // an existing local version with the same hash (just refreshes its
6303
- // timestamp — "ustiga yoziladi, alohida versiya qilinmaydi") or creates
6304
- // a brand-new one, evicting the oldest past localSyncMaxVersions (FIFO —
6305
- // see idbStore.js's saveOrTouchVersion()). Never throws — IndexedDB
6306
- // failures become local-sync-error.
6385
+ // Content-hashes the current `data` and either touches an existing local
6386
+ // version with the same hash (just refreshes its timestamp — "ustiga
6387
+ // yoziladi, alohida versiya qilinmaydi") or creates a brand-new one,
6388
+ // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's
6389
+ // saveOrTouchVersion()). Never throws — IndexedDB failures become
6390
+ // local-sync-error.
6307
6391
  //
6308
6392
  // `force` skips the "nothing changed since the last flush" short-circuit
6309
6393
  // below. The passive triggers (visibilitychange/pagehide/beforeDestroy)
6310
- // fire often on an idle tab and must NOT wake the worker/IndexedDB for
6311
- // no reason, so they leave it in place. saveLocalVersionNow() — an
6312
- // explicit, infrequent, host/modal-initiated "checkpoint now" action —
6313
- // passes force so it never silently does nothing: even unchanged data
6314
- // still gets its existing version's timestamp refreshed via the same
6315
- // hash-dedup path, rather than the caller seeing no effect at all.
6316
- async _flushLocalVersionOnHide(force = false) {
6394
+ // fire often on an idle tab and must NOT wake IndexedDB for no reason, so
6395
+ // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,
6396
+ // host/modal-initiated "checkpoint now" action — passes force so it never
6397
+ // silently does nothing: even unchanged data still gets its existing
6398
+ // version's timestamp refreshed via the same hash-dedup path, rather than
6399
+ // the caller seeing no effect at all.
6400
+ //
6401
+ // `sync` picks hashRows() (main thread) over the Worker-based
6402
+ // hashRowsAsync(). This was a real, observed bug, not a theoretical one:
6403
+ // a Worker round-trip (postMessage out, worker computes, postMessage
6404
+ // back) is one more hop through the event loop than a page that's
6405
+ // actually being torn down (as opposed to merely hidden — e.g. a tab
6406
+ // switch, where the page stays alive indefinitely and the extra hop
6407
+ // costs nothing) can be relied on to survive. visibilitychange/pagehide/
6408
+ // beforeDestroy all fire in exactly that "might be torn down any moment"
6409
+ // window, so they hash synchronously to remove that hop — the whole
6410
+ // flush then only has the unavoidable async part (the IndexedDB write
6411
+ // itself) left, giving it the best realistic chance to land before
6412
+ // teardown. saveLocalVersionNow() (an explicit host/modal action with no
6413
+ // teardown racing it) keeps the Worker, so hashing a large `data` array
6414
+ // doesn't block the main thread during ordinary use.
6415
+ async _flushLocalVersionOnHide(force = false, sync = false) {
6317
6416
  if (!this.localSyncKey) return
6318
6417
  if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush
6319
6418
  const data = this.data
6419
+ // Fires independently of the version-save attempt below, started as
6420
+ // early as possible — see _scheduleBufferWrite()'s comment. This is
6421
+ // the save that's actually likely to survive an imminent teardown;
6422
+ // the version-store write below is best-effort on top of it, reliable
6423
+ // for the common case (tab switch/background, page stays alive).
6424
+ clearTimeout(this._bufferWriteTimer)
6425
+ this._writeBufferNow(this.localSyncKey, data)
6320
6426
  try {
6321
- const hash = await hashRowsAsync(data)
6427
+ const hash = sync ? hashRows(data) : await hashRowsAsync(data)
6322
6428
  if (this.data !== data) return // data changed again mid-hash — a later flush will catch it
6323
6429
  const { version, overwritten } = await saveOrTouchVersion(
6324
6430
  this.localSyncKey, data, hash, this.localSyncMaxVersions
6325
6431
  )
6326
6432
  this._lastFlushedDataRef = data
6433
+ // The version store now has this content durably — the buffer's job
6434
+ // for it is done. Best-effort like everything else here: if this
6435
+ // doesn't complete before teardown, _promoteBufferIfAny() finds the
6436
+ // (now-redundant but harmless, dedup makes it a no-op touch) buffer
6437
+ // on next mount anyway.
6438
+ clearBuffer(this.localSyncKey).catch(() => {})
6327
6439
  this.$emit('local-sync-save', {
6328
6440
  key: this.localSyncKey,
6329
6441
  id: version.id,
@@ -17,11 +17,28 @@
17
17
  // detect "this is the same content as an existing version" — in that case
18
18
  // the existing record's `updatedAt` is bumped instead of creating a new id
19
19
  // ("ustiga yoziladi, alohida versiya qilinmaydi").
20
+ //
21
+ // v3 adds 'buffer', keyPath 'localSyncKey' (one row per key — always
22
+ // overwritten, no history). This exists to solve a real, verified gap: an
23
+ // IndexedDB write started at visibilitychange/pagehide/beforeDestroy has NO
24
+ // guarantee of completing before an actual page reload/close tears the page
25
+ // down (confirmed directly — even a trivial, unrelated IndexedDB write in a
26
+ // bare-minimum repro failed to survive a real page.reload() in Chromium; this
27
+ // is a browser/spec characteristic, not something a Worker or a different
28
+ // object store can fix). 'versions' writes still happen on the leave-signal
29
+ // (reliable for tab-switch/backgrounding, where the page stays alive), but
30
+ // ExcelGrid.vue ALSO writes to 'buffer' on every real edit (debounced, no
31
+ // hash needed, cheap) — at a moment with no teardown race at all — so that
32
+ // whatever the user last typed survives even a reload/close where the
33
+ // leave-signal write doesn't land in time. On the next mount, the buffer (if
34
+ // any) is promoted into 'versions' via saveOrTouchVersion() at leisure, then
35
+ // cleared. See ExcelGrid.vue's _scheduleBufferWrite()/_promoteBufferIfAny().
20
36
  const DB_NAME = 'vue2-excel-grid-sync'
21
37
  const STORE_NAME = 'versions'
38
+ const BUFFER_STORE_NAME = 'buffer'
22
39
  const OLD_STORE_NAME = 'snapshots' // v1 — dropped on upgrade, not migrated
23
40
  const INDEX_BY_KEY = 'by_localSyncKey'
24
- const DB_VERSION = 2
41
+ const DB_VERSION = 3
25
42
 
26
43
  function pad2(n) { return n < 10 ? '0' + n : '' + n }
27
44
 
@@ -52,6 +69,9 @@ function openDb() {
52
69
  const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
53
70
  store.createIndex(INDEX_BY_KEY, 'localSyncKey', { unique: false })
54
71
  }
72
+ if (!db.objectStoreNames.contains(BUFFER_STORE_NAME)) {
73
+ db.createObjectStore(BUFFER_STORE_NAME, { keyPath: 'localSyncKey' })
74
+ }
55
75
  }
56
76
  req.onsuccess = () => resolve(req.result)
57
77
  req.onerror = () => reject(req.error)
@@ -159,3 +179,36 @@ export async function saveOrTouchVersion(localSyncKey, data, hash, maxVersions)
159
179
 
160
180
  return { version: record, overwritten: false }
161
181
  }
182
+
183
+ // The 'buffer' store — see the schema comment at the top of this file. One
184
+ // row per localSyncKey, always overwritten; no hash, no history, no eviction.
185
+ export async function putBuffer(localSyncKey, data) {
186
+ const db = await openDb()
187
+ return new Promise((resolve, reject) => {
188
+ const tx = db.transaction(BUFFER_STORE_NAME, 'readwrite')
189
+ const record = { localSyncKey, data, updatedAt: Date.now() }
190
+ tx.objectStore(BUFFER_STORE_NAME).put(record)
191
+ tx.oncomplete = () => { db.close(); resolve(record) }
192
+ tx.onerror = () => { db.close(); reject(tx.error) }
193
+ })
194
+ }
195
+
196
+ export async function getBuffer(localSyncKey) {
197
+ const db = await openDb()
198
+ return new Promise((resolve, reject) => {
199
+ const tx = db.transaction(BUFFER_STORE_NAME, 'readonly')
200
+ const req = tx.objectStore(BUFFER_STORE_NAME).get(localSyncKey)
201
+ req.onsuccess = () => { db.close(); resolve(req.result || null) }
202
+ req.onerror = () => { db.close(); reject(req.error) }
203
+ })
204
+ }
205
+
206
+ export async function clearBuffer(localSyncKey) {
207
+ const db = await openDb()
208
+ return new Promise((resolve, reject) => {
209
+ const tx = db.transaction(BUFFER_STORE_NAME, 'readwrite')
210
+ tx.objectStore(BUFFER_STORE_NAME).delete(localSyncKey)
211
+ tx.oncomplete = () => { db.close(); resolve() }
212
+ tx.onerror = () => { db.close(); reject(tx.error) }
213
+ })
214
+ }