fix: bound IndexedDB dashboard startup (Closes #529)
This commit is contained in:
parent
3e16dd00d1
commit
11a56b21a1
|
|
@ -417,7 +417,7 @@
|
||||||
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
const draftInbox = createDraftInbox({ storage: localStorage, getCurrentLogin: () => activeFlushLogin });
|
||||||
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
outboxCoordinator.subscribe(() => refreshMyWorkView());
|
||||||
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
const offlineWorkStore = createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB });
|
||||||
await offlineWorkStore.ready();
|
let offlineStorageReady = await offlineWorkStore.ready();
|
||||||
function renderOfflineTodayStatus(status) {
|
function renderOfflineTodayStatus(status) {
|
||||||
const container = qs('#offline-today-readiness');
|
const container = qs('#offline-today-readiness');
|
||||||
const label = qs('#offline-today-status');
|
const label = qs('#offline-today-status');
|
||||||
|
|
@ -4735,12 +4735,16 @@
|
||||||
const offlineStatus = qs('#offline-status');
|
const offlineStatus = qs('#offline-status');
|
||||||
const keepWorkOffline = qs('#keep-work-offline');
|
const keepWorkOffline = qs('#keep-work-offline');
|
||||||
const offlineWorkStatus = qs('#offline-work-status');
|
const offlineWorkStatus = qs('#offline-work-status');
|
||||||
|
const retryOfflineStorage = qs('#retry-offline-storage');
|
||||||
const deliveryReceipts = qs('#delivery-receipts');
|
const deliveryReceipts = qs('#delivery-receipts');
|
||||||
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
const deliveryReceiptStatus = qs('#delivery-receipt-status');
|
||||||
async function updateOfflineWorkControls(message) {
|
async function updateOfflineWorkControls(message) {
|
||||||
keepWorkOffline.checked = offlineWorkStore.enabled();
|
keepWorkOffline.checked = offlineWorkStore.enabled();
|
||||||
|
keepWorkOffline.disabled = !offlineStorageReady;
|
||||||
|
retryOfflineStorage.hidden = offlineStorageReady;
|
||||||
const saved = await offlineWorkStore.load();
|
const saved = await offlineWorkStore.load();
|
||||||
offlineWorkStatus.textContent = message || (saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
offlineWorkStatus.textContent = message || (!offlineStorageReady ?
|
||||||
|
'Offline saving unavailable · online work remains live.' : saved ? 'Saved ' + fmt(saved.saved_at) + ' · expires after 7 days.' :
|
||||||
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
(keepWorkOffline.checked ? 'Waiting for a healthy live refresh.' : 'Off · no work data is stored.'));
|
||||||
}
|
}
|
||||||
async function updateDeliveryReceiptControls(message) {
|
async function updateDeliveryReceiptControls(message) {
|
||||||
|
|
@ -4848,6 +4852,18 @@
|
||||||
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
renderOfflineTodayStatus({ total:0, ready:0, failed:0, pending:0 });
|
||||||
await updateOfflineWorkControls('Offline work data cleared.');
|
await updateOfflineWorkControls('Offline work data cleared.');
|
||||||
});
|
});
|
||||||
|
retryOfflineStorage.addEventListener('click', async () => {
|
||||||
|
retryOfflineStorage.disabled = true;
|
||||||
|
offlineWorkStatus.textContent = 'Retrying offline saving…';
|
||||||
|
offlineStorageReady = await offlineWorkStore.retry();
|
||||||
|
retryOfflineStorage.disabled = false;
|
||||||
|
if (offlineStorageReady && offlineWorkStore.enabled() && liveMode && lastContextSnapshot) {
|
||||||
|
await offlineWorkStore.save({ ...lastContextSnapshot, notifications:lastNotifications,
|
||||||
|
notification_pagination:notificationPagination });
|
||||||
|
}
|
||||||
|
await updateOfflineWorkControls(offlineStorageReady ? 'Offline saving restored.' :
|
||||||
|
'Offline saving unavailable · online work remains live.');
|
||||||
|
});
|
||||||
qs('#retry-offline-today').addEventListener('click', () =>
|
qs('#retry-offline-today').addEventListener('click', () =>
|
||||||
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
offlineToday.retry(confirmedOwnerLogin, todayMyWork)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||||
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
<button id="clear-offline-work" type="button">Clear offline work data</button>
|
||||||
|
<button id="retry-offline-storage" type="button" hidden>Retry offline saving</button>
|
||||||
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-work-status" role="status" aria-live="polite"></span>
|
||||||
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
<span class="offline-today-readiness" id="offline-today-readiness" hidden>
|
||||||
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
<span class="small" id="offline-today-status" role="status" aria-live="polite"></span>
|
||||||
|
|
|
||||||
|
|
@ -85,12 +85,32 @@
|
||||||
|
|
||||||
function createOfflineWorkStore({
|
function createOfflineWorkStore({
|
||||||
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
storage, indexedDB, transaction, now = () => new Date(), maxAgeMs = DEFAULT_MAX_AGE_MS,
|
||||||
maxDetails = 10,
|
maxDetails = 10, initializationTimeoutMs = 1500,
|
||||||
}) {
|
}) {
|
||||||
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
const transact = transaction || createIndexedDbTransaction(indexedDB);
|
||||||
let migrationPromise;
|
let migrationPromise;
|
||||||
|
let readinessPromise;
|
||||||
|
let readinessGeneration = 0;
|
||||||
|
let readinessState = transact ? { state:'initializing', reason:'' } : { state:'ready', reason:'' };
|
||||||
let recordCache = null;
|
let recordCache = null;
|
||||||
function migrateLegacy() {
|
let pendingDurableClear = false;
|
||||||
|
|
||||||
|
function hydrateLegacyCache() {
|
||||||
|
const cache = new Map();
|
||||||
|
try {
|
||||||
|
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
||||||
|
if (snapshot) cache.set('snapshot', { ...snapshot, id:'snapshot' });
|
||||||
|
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
||||||
|
if (Array.isArray(details)) details.forEach(record => {
|
||||||
|
if (!record?.key || !record?.user_login || !record?.data) return;
|
||||||
|
const id = 'detail:' + record.user_login + ':' + record.key;
|
||||||
|
cache.set(id, { ...record, id });
|
||||||
|
});
|
||||||
|
} catch (_) { cache.clear(); }
|
||||||
|
recordCache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacy(generation) {
|
||||||
if (!transact) return Promise.resolve(true);
|
if (!transact) return Promise.resolve(true);
|
||||||
if (!migrationPromise) migrationPromise = (async () => {
|
if (!migrationPromise) migrationPromise = (async () => {
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
|
|
@ -113,46 +133,82 @@
|
||||||
if (!(await records.get(id))) await records.put({ ...record, id });
|
if (!(await records.get(id))) await records.put({ ...record, id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||||
storage.removeItem(SNAPSHOT_KEY);
|
storage.removeItem(SNAPSHOT_KEY);
|
||||||
storage.removeItem(DETAILS_KEY);
|
storage.removeItem(DETAILS_KEY);
|
||||||
return true;
|
return true;
|
||||||
})().catch(() => false);
|
})().catch(() => false);
|
||||||
return migrationPromise;
|
return migrationPromise;
|
||||||
}
|
}
|
||||||
function ready() {
|
|
||||||
if (!transact) return Promise.resolve(true);
|
async function initialize(generation) {
|
||||||
if (recordCache) return Promise.resolve(true);
|
const migrated = await migrateLegacy(generation);
|
||||||
return migrateLegacy().then(migrated => {
|
if (!migrated || generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||||
if (!migrated) {
|
if (pendingDurableClear) {
|
||||||
recordCache = new Map();
|
await transact(async records => { await records.clear(); });
|
||||||
try {
|
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||||
const snapshot = JSON.parse(storage.getItem(SNAPSHOT_KEY) || 'null');
|
pendingDurableClear = false;
|
||||||
if (snapshot) recordCache.set('snapshot', { ...snapshot, id:'snapshot' });
|
recordCache = new Map();
|
||||||
const details = JSON.parse(storage.getItem(DETAILS_KEY) || '[]');
|
return true;
|
||||||
if (Array.isArray(details)) details.forEach(record => {
|
}
|
||||||
if (!record?.key || !record?.user_login || !record?.data) return;
|
return transact(async records => {
|
||||||
const id = 'detail:' + record.user_login + ':' + record.key;
|
const currentTime = now().getTime();
|
||||||
recordCache.set(id, { ...record, id });
|
const valid = [];
|
||||||
});
|
for (const record of await records.getAll()) {
|
||||||
} catch (_) { recordCache.clear(); }
|
const savedAt = Date.parse(record?.saved_at || '');
|
||||||
return false;
|
const validShape = record?.id === 'snapshot' ?
|
||||||
|
record.version === VERSION && record.user_login && record.data :
|
||||||
|
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
||||||
|
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
||||||
|
if (record?.id) await records.delete(record.id);
|
||||||
|
} else valid.push(record);
|
||||||
}
|
}
|
||||||
return transact(async records => {
|
if (generation !== readinessGeneration || readinessState.state === 'degraded') return false;
|
||||||
const currentTime = now().getTime();
|
recordCache = new Map(valid.map(record => [record.id, record]));
|
||||||
const valid = [];
|
return true;
|
||||||
for (const record of await records.getAll()) {
|
});
|
||||||
const savedAt = Date.parse(record?.saved_at || '');
|
}
|
||||||
const validShape = record?.id === 'snapshot' ?
|
|
||||||
record.version === VERSION && record.user_login && record.data :
|
function startReady() {
|
||||||
record?.id?.startsWith('detail:') && record.user_login && record.key && record.data;
|
const generation = ++readinessGeneration;
|
||||||
if (!validShape || !Number.isFinite(savedAt) || currentTime - savedAt > maxAgeMs) {
|
readinessState = { state:'initializing', reason:'' };
|
||||||
if (record?.id) await records.delete(record.id);
|
const operation = initialize(generation).catch(() => false);
|
||||||
} else valid.push(record);
|
readinessPromise = new Promise(resolve => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (generation !== readinessGeneration || readinessState.state !== 'initializing') return;
|
||||||
|
hydrateLegacyCache();
|
||||||
|
readinessState = { state:'degraded', reason:'deadline' };
|
||||||
|
resolve(false);
|
||||||
|
}, Math.max(1, Number(initializationTimeoutMs) || 1500));
|
||||||
|
operation.then(available => {
|
||||||
|
if (generation !== readinessGeneration || readinessState.state === 'degraded') return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (available) readinessState = { state:'ready', reason:'' };
|
||||||
|
else {
|
||||||
|
hydrateLegacyCache();
|
||||||
|
readinessState = { state:'degraded', reason:'unavailable' };
|
||||||
}
|
}
|
||||||
recordCache = new Map(valid.map(record => [record.id, record]));
|
resolve(Boolean(available));
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}).catch(() => false);
|
});
|
||||||
|
return readinessPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready() {
|
||||||
|
if (!transact || readinessState.state === 'ready') return Promise.resolve(true);
|
||||||
|
if (readinessState.state === 'degraded') return Promise.resolve(false);
|
||||||
|
return readinessPromise || startReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
return { ...readinessState };
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
if (!transact) return Promise.resolve(true);
|
||||||
|
migrationPromise = undefined;
|
||||||
|
readinessPromise = undefined;
|
||||||
|
return startReady();
|
||||||
}
|
}
|
||||||
function enabled() {
|
function enabled() {
|
||||||
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
try { return storage.getItem(ENABLED_KEY) === 'true'; }
|
||||||
|
|
@ -165,6 +221,11 @@
|
||||||
storage.removeItem(SNAPSHOT_KEY);
|
storage.removeItem(SNAPSHOT_KEY);
|
||||||
storage.removeItem(DETAILS_KEY);
|
storage.removeItem(DETAILS_KEY);
|
||||||
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
} catch (_) { /* IndexedDB remains the source of truth. */ }
|
||||||
|
if (readinessState.state === 'degraded') {
|
||||||
|
pendingDurableClear = true;
|
||||||
|
recordCache = new Map();
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
migrationPromise = Promise.resolve(true);
|
migrationPromise = Promise.resolve(true);
|
||||||
return transact(async records => {
|
return transact(async records => {
|
||||||
await records.clear();
|
await records.clear();
|
||||||
|
|
@ -234,7 +295,7 @@
|
||||||
};
|
};
|
||||||
if (transact) {
|
if (transact) {
|
||||||
record.id = 'detail:' + login + ':' + key;
|
record.id = 'detail:' + login + ':' + key;
|
||||||
return ready().then(() => transact(async records => {
|
return ready().then(available => available ? transact(async records => {
|
||||||
const existing = (await records.getAll()).filter(candidate =>
|
const existing = (await records.getAll()).filter(candidate =>
|
||||||
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
candidate?.id?.startsWith('detail:') && candidate.id !== record.id
|
||||||
);
|
);
|
||||||
|
|
@ -246,7 +307,7 @@
|
||||||
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
overflow.forEach(candidate => recordCache.delete(candidate.id));
|
||||||
recordCache.set(record.id, record);
|
recordCache.set(record.id, record);
|
||||||
return true;
|
return true;
|
||||||
})).catch(() => false);
|
}) : false).catch(() => false);
|
||||||
}
|
}
|
||||||
const records = readDetails().filter(candidate =>
|
const records = readDetails().filter(candidate =>
|
||||||
!(candidate?.key === key && candidate?.user_login === login)
|
!(candidate?.key === key && candidate?.user_login === login)
|
||||||
|
|
@ -313,11 +374,11 @@
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (transact) {
|
if (transact) {
|
||||||
return ready().then(() => transact(async records => {
|
return ready().then(available => available ? transact(async records => {
|
||||||
await records.put(record);
|
await records.put(record);
|
||||||
recordCache.set(record.id, record);
|
recordCache.set(record.id, record);
|
||||||
return true;
|
return true;
|
||||||
})).catch(() => false);
|
}) : false).catch(() => false);
|
||||||
}
|
}
|
||||||
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
try { storage.setItem(SNAPSHOT_KEY, JSON.stringify(record)); }
|
||||||
catch (_) { return false; }
|
catch (_) { return false; }
|
||||||
|
|
@ -357,7 +418,7 @@
|
||||||
return { ...record.data, saved_at: record.saved_at };
|
return { ...record.data, saved_at: record.saved_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { enabled, setEnabled, ready, save, load, saveDetail, loadDetail, clear };
|
return { enabled, setEnabled, ready, retry, status, save, load, saveDetail, loadDetail, clear };
|
||||||
}
|
}
|
||||||
|
|
||||||
return createOfflineWorkStore;
|
return createOfflineWorkStore;
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,91 @@ process.stdout.write(JSON.stringify({recordIds:[...records.keys()]}));
|
||||||
assert result["recordIds"] == []
|
assert result["recordIds"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_degrades_within_deadline_when_indexeddb_never_settles():
|
||||||
|
result = run_async_scenario("""
|
||||||
|
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||||
|
values.set('stackchain.offline-work.snapshot.v1', JSON.stringify({
|
||||||
|
version:1, user_login:'timmy', saved_at:'2026-08-07T12:00:00.000Z',
|
||||||
|
data:{user:{login:'timmy'}, issues:[{number:7, title:'Still available'}], pull_requests:[], notifications:[]},
|
||||||
|
}));
|
||||||
|
const neverTransaction = async () => new Promise(() => {});
|
||||||
|
const store = createOfflineWorkStore({
|
||||||
|
storage, transaction:neverTransaction, initializationTimeoutMs:20,
|
||||||
|
now:() => new Date('2026-08-07T12:01:00Z'),
|
||||||
|
});
|
||||||
|
const started = Date.now();
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
store.ready().then(value => ({value, status:store.status(), saved:store.load('timmy')})),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 100)),
|
||||||
|
]);
|
||||||
|
process.stdout.write(JSON.stringify({...outcome, elapsed:Date.now() - started,
|
||||||
|
legacySnapshot:values.has('stackchain.offline-work.snapshot.v1')}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result.get("timedOut", False) is False
|
||||||
|
assert result["value"] is False
|
||||||
|
assert result["status"] == {"state": "degraded", "reason": "deadline"}
|
||||||
|
assert result["saved"]["issues"][0]["title"] == "Still available"
|
||||||
|
assert result["legacySnapshot"] is True
|
||||||
|
assert result["elapsed"] < 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_degraded_store_rejects_saves_without_waiting_for_stalled_indexeddb():
|
||||||
|
result = run_async_scenario("""
|
||||||
|
values.set('stackchain.offline-work.enabled.v1', 'true');
|
||||||
|
const neverTransaction = async () => new Promise(() => {});
|
||||||
|
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
||||||
|
await store.ready();
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]})
|
||||||
|
.then(value => ({value})),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
||||||
|
]);
|
||||||
|
process.stdout.write(JSON.stringify(outcome));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"value": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_degraded_store_clears_legacy_data_without_waiting_for_indexeddb():
|
||||||
|
result = run_async_scenario("""
|
||||||
|
values.set('stackchain.offline-work.snapshot.v1', '{"private":"snapshot"}');
|
||||||
|
values.set('stackchain.offline-work.details.v1', '[{"private":"detail"}]');
|
||||||
|
const neverTransaction = async () => new Promise(() => {});
|
||||||
|
const store = createOfflineWorkStore({storage, transaction:neverTransaction, initializationTimeoutMs:10});
|
||||||
|
await store.ready();
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
store.clear().then(value => ({value, keys:[...values.keys()]})),
|
||||||
|
new Promise(resolve => setTimeout(() => resolve({timedOut:true}), 80)),
|
||||||
|
]);
|
||||||
|
process.stdout.write(JSON.stringify(outcome));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {"value": True, "keys": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_recovers_durable_saving_after_initialization_deadline():
|
||||||
|
result = run_async_scenario("""
|
||||||
|
let stalled = true;
|
||||||
|
const recoveringTransaction = async work => stalled ? new Promise(() => {}) : transaction(work);
|
||||||
|
const store = createOfflineWorkStore({storage, transaction:recoveringTransaction, initializationTimeoutMs:10});
|
||||||
|
const initial = await store.ready();
|
||||||
|
stalled = false;
|
||||||
|
const recovered = await store.retry();
|
||||||
|
store.setEnabled(true);
|
||||||
|
const saved = await store.save({user:{login:'timmy'}, issues:[], pull_requests:[], notifications:[]});
|
||||||
|
process.stdout.write(JSON.stringify({initial, recovered, saved, status:store.status(), recordIds:[...records.keys()]}));
|
||||||
|
""")
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"initial": False,
|
||||||
|
"recovered": True,
|
||||||
|
"saved": True,
|
||||||
|
"status": {"state": "ready", "reason": ""},
|
||||||
|
"recordIds": ["snapshot"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
|
def test_opted_in_snapshot_survives_restart_with_only_queue_card_fields():
|
||||||
result = run_scenario("""
|
result = run_scenario("""
|
||||||
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
const store = createOfflineWorkStore({storage, now:() => new Date('2026-08-07T12:00:00Z')});
|
||||||
|
|
@ -348,6 +433,10 @@ async def test_dashboard_uses_async_indexeddb_offline_store_end_to_end():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
|
assert "createOfflineWorkStore({ storage: localStorage, indexedDB:window.indexedDB })" in html
|
||||||
|
assert "let offlineStorageReady = await offlineWorkStore.ready();" in html
|
||||||
|
assert 'id="retry-offline-storage"' in html
|
||||||
|
assert "Offline saving unavailable · online work remains live." in html
|
||||||
|
assert "await offlineWorkStore.retry()" in html
|
||||||
assert "async function hydrateOfflineWork" in html
|
assert "async function hydrateOfflineWork" in html
|
||||||
assert "const saved = await offlineWorkStore.load();" in html
|
assert "const saved = await offlineWorkStore.load();" in html
|
||||||
assert "await offlineWorkStore.save({" in html
|
assert "await offlineWorkStore.save({" in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user