vue2-excel-grid 2.0.0 → 2.0.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.
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.1",
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 },
@@ -2526,6 +2526,7 @@ const ExcelGridComponent = {
2526
2526
  clearTimeout(this._onInputTimer)
2527
2527
  clearTimeout(this._dragCleanupTimer)
2528
2528
  clearTimeout(this._widthSyncChainResetTimer)
2529
+ clearTimeout(this._bufferWriteTimer)
2529
2530
  if (this._dragScrollRAF) cancelAnimationFrame(this._dragScrollRAF)
2530
2531
  document.removeEventListener('mousemove', this.onGlobalMouseMove)
2531
2532
  document.removeEventListener('mouseup', this.onGlobalMouseUp)
@@ -2537,7 +2538,10 @@ const ExcelGridComponent = {
2537
2538
  window.removeEventListener('pagehide', this._onPageHideForLocalSync)
2538
2539
  // Best-effort — the component may be torn down (SPA route change) without
2539
2540
  // the page itself unloading, which visibilitychange/pagehide won't catch.
2540
- this._flushLocalVersionOnHide()
2541
+ // sync: true — see _flushLocalVersionOnHide()'s comment; a component
2542
+ // being destroyed is exactly the same "might not survive an async hop"
2543
+ // situation as the other two triggers.
2544
+ this._flushLocalVersionOnHide(false, true)
2541
2545
  if (this.isFullscreen) {
2542
2546
  document.body.style.overflow = this._prevBodyOverflow || ''
2543
2547
  document.removeEventListener('keydown', this._onFullscreenKeyDown)
@@ -6055,6 +6059,7 @@ const ExcelGridComponent = {
6055
6059
  // All edit paths already use spread ({ ...row }) so frozen rows are safe.
6056
6060
  const data = result.data.map(row => Object.freeze(row))
6057
6061
  this.$emit('input', data)
6062
+ this._scheduleBufferWrite(data)
6058
6063
  this.$emit('update:columns', columns)
6059
6064
 
6060
6065
  const hasMerges = merges && merges.length > 0
@@ -6170,6 +6175,7 @@ const ExcelGridComponent = {
6170
6175
  }
6171
6176
  }
6172
6177
  this.$emit('input', newData)
6178
+ this._scheduleBufferWrite(newData)
6173
6179
  },
6174
6180
 
6175
6181
  // Emit 'update:merges' and record a merges diff entry.
@@ -6203,6 +6209,7 @@ const ExcelGridComponent = {
6203
6209
  newData.length = entry.before.rowCount
6204
6210
  }
6205
6211
  this.$emit('input', newData)
6212
+ this._scheduleBufferWrite(newData)
6206
6213
  } else if (entry.type === 'merges') {
6207
6214
  this.internalMerges = entry.before.slice()
6208
6215
  this.$emit('update:merges', entry.before.slice())
@@ -6225,6 +6232,7 @@ const ExcelGridComponent = {
6225
6232
  newData[entry.after.indices[i]] = entry.after.rows[i]
6226
6233
  }
6227
6234
  this.$emit('input', newData)
6235
+ this._scheduleBufferWrite(newData)
6228
6236
  } else if (entry.type === 'merges') {
6229
6237
  this.internalMerges = entry.after.slice()
6230
6238
  this.$emit('update:merges', entry.after.slice())
@@ -6267,6 +6275,7 @@ const ExcelGridComponent = {
6267
6275
  // already exist for this key (e.g. saved in an earlier visit).
6268
6276
  async _loadLocalVersionsOnMount() {
6269
6277
  if (!this.localSyncKey) return
6278
+ await this._promoteBufferIfAny()
6270
6279
  try {
6271
6280
  const versions = await listVersions(this.localSyncKey)
6272
6281
  if (!versions.length) return
@@ -6281,12 +6290,72 @@ const ExcelGridComponent = {
6281
6290
  }
6282
6291
  },
6283
6292
 
6293
+ // Debounced (800ms), called from the grid's own real-change points
6294
+ // (_emitDataChange, import, undo, redo — the exact same "user worked"
6295
+ // signal _flushLocalVersionOnHide() relies on, never a $watch on `data`,
6296
+ // for the same reason spelled out on the localSyncKey prop's JSDoc: a
6297
+ // host loading rows from an API must never look like a user edit).
6298
+ //
6299
+ // Writes to IndexedDB's 'buffer' store (idbStore.js — one row per key, no
6300
+ // hash, no history) at a moment with no teardown race at all — the user
6301
+ // is actively still on the page. This is what actually fixes reload/
6302
+ // close data loss: a version-store write ATTEMPTED at the leave-signal
6303
+ // has no guarantee of finishing before an actual page teardown (verified
6304
+ // directly — even a trivial, unrelated IndexedDB write failed to survive
6305
+ // a real page.reload() in Chromium), but a buffer write that already
6306
+ // completed minutes or seconds earlier, during ordinary editing, needs no
6307
+ // such guarantee — it's already durably there. _promoteBufferIfAny()
6308
+ // turns it into a real version next time the grid mounts.
6309
+ _scheduleBufferWrite(data) {
6310
+ if (!this.localSyncKey) return
6311
+ clearTimeout(this._bufferWriteTimer)
6312
+ this._bufferWriteTimer = setTimeout(() => { this._writeBufferNow(data) }, 800)
6313
+ },
6314
+
6315
+ async _writeBufferNow(data) {
6316
+ if (!this.localSyncKey) return
6317
+ try {
6318
+ await putBuffer(this.localSyncKey, data)
6319
+ } catch (error) {
6320
+ this.$emit('local-sync-error', { action: 'save', error })
6321
+ }
6322
+ },
6323
+
6324
+ // Called once from _loadLocalVersionsOnMount(), before this key's version
6325
+ // list is read — folds a leftover buffer (from an edit that was saved
6326
+ // during the PREVIOUS visit but never made it into 'versions' — the
6327
+ // reload/close case this whole mechanism exists for) into a real version.
6328
+ // No time pressure here, so this uses the Worker-backed hashRowsAsync()
6329
+ // like any other non-leave-signal path.
6330
+ async _promoteBufferIfAny() {
6331
+ try {
6332
+ const buffered = await getBuffer(this.localSyncKey)
6333
+ if (!buffered) return
6334
+ const hash = await hashRowsAsync(buffered.data)
6335
+ const { version, overwritten } = await saveOrTouchVersion(
6336
+ this.localSyncKey, buffered.data, hash, this.localSyncMaxVersions
6337
+ )
6338
+ await clearBuffer(this.localSyncKey)
6339
+ this.$emit('local-sync-save', {
6340
+ key: this.localSyncKey,
6341
+ id: version.id,
6342
+ rows: buffered.data.length,
6343
+ updatedAt: version.updatedAt,
6344
+ overwritten
6345
+ })
6346
+ } catch (error) {
6347
+ this.$emit('local-sync-error', { action: 'save', error })
6348
+ }
6349
+ },
6350
+
6351
+ // `sync: true` — see _flushLocalVersionOnHide()'s comment on why the
6352
+ // passive leave-triggers can't afford a Worker round-trip.
6284
6353
  _onVisibilityChangeForLocalSync() {
6285
- if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide()
6354
+ if (document.visibilityState === 'hidden') this._flushLocalVersionOnHide(false, true)
6286
6355
  },
6287
6356
 
6288
6357
  _onPageHideForLocalSync() {
6289
- this._flushLocalVersionOnHide()
6358
+ this._flushLocalVersionOnHide(false, true)
6290
6359
  },
6291
6360
 
6292
6361
  // The core save-on-leave path — replaces a per-edit autosave (see the
@@ -6296,34 +6365,60 @@ const ExcelGridComponent = {
6296
6365
  // does the latter). Triggered from mounted()'s visibilitychange/pagehide
6297
6366
  // listeners and beforeDestroy() — see those for the three trigger points.
6298
6367
  //
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.
6368
+ // Content-hashes the current `data` and either touches an existing local
6369
+ // version with the same hash (just refreshes its timestamp — "ustiga
6370
+ // yoziladi, alohida versiya qilinmaydi") or creates a brand-new one,
6371
+ // evicting the oldest past localSyncMaxVersions (FIFO — see idbStore.js's
6372
+ // saveOrTouchVersion()). Never throws — IndexedDB failures become
6373
+ // local-sync-error.
6307
6374
  //
6308
6375
  // `force` skips the "nothing changed since the last flush" short-circuit
6309
6376
  // 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) {
6377
+ // fire often on an idle tab and must NOT wake IndexedDB for no reason, so
6378
+ // they leave it in place. saveLocalVersionNow() — an explicit, infrequent,
6379
+ // host/modal-initiated "checkpoint now" action — passes force so it never
6380
+ // silently does nothing: even unchanged data still gets its existing
6381
+ // version's timestamp refreshed via the same hash-dedup path, rather than
6382
+ // the caller seeing no effect at all.
6383
+ //
6384
+ // `sync` picks hashRows() (main thread) over the Worker-based
6385
+ // hashRowsAsync(). This was a real, observed bug, not a theoretical one:
6386
+ // a Worker round-trip (postMessage out, worker computes, postMessage
6387
+ // back) is one more hop through the event loop than a page that's
6388
+ // actually being torn down (as opposed to merely hidden — e.g. a tab
6389
+ // switch, where the page stays alive indefinitely and the extra hop
6390
+ // costs nothing) can be relied on to survive. visibilitychange/pagehide/
6391
+ // beforeDestroy all fire in exactly that "might be torn down any moment"
6392
+ // window, so they hash synchronously to remove that hop — the whole
6393
+ // flush then only has the unavoidable async part (the IndexedDB write
6394
+ // itself) left, giving it the best realistic chance to land before
6395
+ // teardown. saveLocalVersionNow() (an explicit host/modal action with no
6396
+ // teardown racing it) keeps the Worker, so hashing a large `data` array
6397
+ // doesn't block the main thread during ordinary use.
6398
+ async _flushLocalVersionOnHide(force = false, sync = false) {
6317
6399
  if (!this.localSyncKey) return
6318
6400
  if (!force && this.data === this._lastFlushedDataRef) return // nothing changed since the last flush
6319
6401
  const data = this.data
6402
+ // Fires independently of the version-save attempt below, started as
6403
+ // early as possible — see _scheduleBufferWrite()'s comment. This is
6404
+ // the save that's actually likely to survive an imminent teardown;
6405
+ // the version-store write below is best-effort on top of it, reliable
6406
+ // for the common case (tab switch/background, page stays alive).
6407
+ clearTimeout(this._bufferWriteTimer)
6408
+ this._writeBufferNow(data)
6320
6409
  try {
6321
- const hash = await hashRowsAsync(data)
6410
+ const hash = sync ? hashRows(data) : await hashRowsAsync(data)
6322
6411
  if (this.data !== data) return // data changed again mid-hash — a later flush will catch it
6323
6412
  const { version, overwritten } = await saveOrTouchVersion(
6324
6413
  this.localSyncKey, data, hash, this.localSyncMaxVersions
6325
6414
  )
6326
6415
  this._lastFlushedDataRef = data
6416
+ // The version store now has this content durably — the buffer's job
6417
+ // for it is done. Best-effort like everything else here: if this
6418
+ // doesn't complete before teardown, _promoteBufferIfAny() finds the
6419
+ // (now-redundant but harmless, dedup makes it a no-op touch) buffer
6420
+ // on next mount anyway.
6421
+ clearBuffer(this.localSyncKey).catch(() => {})
6327
6422
  this.$emit('local-sync-save', {
6328
6423
  key: this.localSyncKey,
6329
6424
  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
+ }