teamplay 0.4.0-alpha.32 → 0.4.0-alpha.34

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/index.d.ts CHANGED
@@ -94,6 +94,8 @@ export function useDidUpdate (fn: () => EffectCleanup, deps?: any[]): void
94
94
  export function useOnce (condition: any, fn: () => EffectCleanup): void
95
95
  export function useSyncEffect (fn: () => EffectCleanup, deps?: any[]): void
96
96
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
97
+ export function getSubscriptionGcDelay (): number
98
+ export function setSubscriptionGcDelay (ms?: number | null): number
97
99
  export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
98
100
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
99
101
  export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
package/index.js CHANGED
@@ -66,6 +66,7 @@ export {
66
66
  useSyncEffect
67
67
  } from './react/helpers.js'
68
68
  export { connection, setConnection, getConnection, fetchOnly, setFetchOnly, publicOnly, setPublicOnly } from './orm/connection.js'
69
+ export { getSubscriptionGcDelay, setSubscriptionGcDelay } from './orm/subscriptionGcDelay.js'
69
70
  export { useId, useNow, useScheduleUpdate, useTriggerUpdate } from './react/helpers.js'
70
71
  export { GUID_PATTERN, hasMany, hasOne, hasManyFlags, belongsTo, pickFormFields } from '@teamplay/schema'
71
72
  export { aggregation, aggregationHeader as __aggregationHeader } from '@teamplay/utils/aggregation'
@@ -370,6 +370,25 @@ Compatibility mode intentionally aligns mutators with Racer. This differs from c
370
370
  Migration note: compat behavior is intentionally Racer-aligned and may differ from core mutators.
371
371
  Composite compat mutators (`setEach`, `setDiffDeep`) apply updates atomically for Teamplay-scheduled observers via the runtime batch scheduler.
372
372
 
373
+ ### Subscription GC Delay (Compat)
374
+
375
+ To reduce UI blink on rapid `unsub -> sub` cycles, compat uses an unload grace period for docs/queries.
376
+
377
+ - Default in compat: `300ms`
378
+ - Default in non-compat: `0ms` (immediate cleanup)
379
+
380
+ You can tune it globally:
381
+
382
+ ```js
383
+ import { getSubscriptionGcDelay, setSubscriptionGcDelay } from 'teamplay'
384
+
385
+ setSubscriptionGcDelay(500)
386
+ console.log(getSubscriptionGcDelay()) // 500
387
+ ```
388
+
389
+ When refCount drops to `0`, unsubscribe/destroy is scheduled after this delay.
390
+ If a new subscribe arrives before timeout, pending destroy is cancelled and the same doc/query instance is reused.
391
+
373
392
  ### set(value) and set(path, value)
374
393
 
375
394
  `SignalCompat` accepts both:
@@ -571,7 +590,8 @@ General notes:
571
590
  - Hooks should be used inside `observer()` components to get reactive updates.
572
591
  - Sync hooks (`useDoc`, `useQuery`) use Suspense by default (via `useSub`).
573
592
  - Async hooks (`useAsyncDoc`, `useAsyncQuery`) never throw; they return `undefined` until ready.
574
- - Batch hooks are **aliases**, no batching is implemented.
593
+ - Batch hooks use a Suspense batch barrier (`useBatch`) and wait for both
594
+ subscribe promises and DataTree materialization readiness.
575
595
 
576
596
  ### Events
577
597
 
@@ -744,10 +764,12 @@ if (!user) return 'Loading...'
744
764
 
745
765
  Returns `undefined` until subscription resolves.
746
766
 
747
- #### Batch aliases
767
+ #### Batch variants
748
768
 
749
- `useBatchDoc` / `useBatchDoc$` are aliases to `useDoc` / `useDoc$`.
750
- Batching is not implemented in Teamplay.
769
+ `useBatchDoc` / `useBatchDoc$` participate in batch Suspense flow:
770
+ - they register subscribe promises for `useBatch()`;
771
+ - they also register a **materialization readiness check**:
772
+ doc is considered ready only when it is visible in DataTree (or explicitly missing).
751
773
 
752
774
  ### Query Hooks
753
775
 
@@ -784,9 +806,13 @@ if (!users) return 'Loading...'
784
806
 
785
807
  Async variant: no Suspense, returns `undefined` until ready.
786
808
 
787
- #### Batch aliases
809
+ #### Batch variants
788
810
 
789
- `useBatchQuery` / `useBatchQuery$` are aliases to `useQuery` / `useQuery$`.
811
+ `useBatchQuery` / `useBatchQuery$` participate in batch Suspense flow:
812
+ - they register subscribe promises for `useBatch()`;
813
+ - they register a **query readiness check**:
814
+ query ids must be materialized in DataTree, and each `collection.id` from ids must
815
+ be visible in DataTree (or explicitly missing).
790
816
 
791
817
  ### Query Helpers
792
818
 
@@ -800,7 +826,7 @@ const [users] = useQueryIds('users', ['b', 'a'])
800
826
  Options:
801
827
  - `reverse: true` — reverse order of IDs before mapping.
802
828
 
803
- `useBatchQueryIds` and `useAsyncQueryIds` are alias/async variants.
829
+ `useBatchQueryIds` and `useAsyncQueryIds` are batch/async variants.
804
830
 
805
831
  #### `useQueryDoc`
806
832
 
@@ -815,16 +841,20 @@ Implementation details:
815
841
  - Adds default `$sort: { createdAt: -1 }` if `$sort` is missing
816
842
 
817
843
  `useQueryDoc$` returns only the doc signal (or `undefined`).
818
- `useBatchQueryDoc` / `useAsyncQueryDoc` are alias/async variants.
844
+ `useBatchQueryDoc` / `useAsyncQueryDoc` are batch/async variants.
819
845
 
820
- ### Batching Placeholder
846
+ ### Batch Barrier
821
847
 
822
- `useBatch()` is a no-op placeholder.
823
- All batch hooks are **aliases** to their non-batch versions.
848
+ `useBatch()` is a Suspense barrier for batch hooks.
824
849
 
825
- ```js
826
- useBatch() // does nothing in Teamplay
827
- ```
850
+ It throws while:
851
+ - batch subscribe promises are pending;
852
+ - or subscribe promises are resolved but requested docs/queries are not yet
853
+ materialized in DataTree.
854
+
855
+ After `useBatch()` stops throwing in compat mode, immediate reads via
856
+ `useLocal(...).get(...)` for already requested batch entities should not produce
857
+ transient `undefined` caused by materialization races.
828
858
 
829
859
  ## Examples
830
860
 
@@ -2,6 +2,10 @@ import { getRootSignal, GLOBAL_ROOT_ID } from '../Root.js'
2
2
  import useSub, { useAsyncSub } from '../../react/useSub.js'
3
3
  import universal$ from '../../react/universal$.js'
4
4
  import * as promiseBatcher from '../../react/promiseBatcher.js'
5
+ import { getRaw } from '../dataTree.js'
6
+ import { getConnection } from '../connection.js'
7
+ import { isCompatEnv } from '../compatEnv.js'
8
+ import { hashQuery, QUERIES } from '../Query.js'
5
9
 
6
10
  const $root = getRootSignal({ rootId: GLOBAL_ROOT_ID, rootFunction: universal$ })
7
11
 
@@ -97,6 +101,7 @@ export function useBatchDoc (collection, id, options) {
97
101
 
98
102
  export function useBatchDoc$ (collection, id, _options) {
99
103
  const $doc = getDocSignal(collection, id, 'useBatchDoc')
104
+ registerBatchDocReadinessCheck(collection, getDocIdFromSignal($doc))
100
105
  const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
101
106
  return useSub($doc, undefined, options)
102
107
  }
@@ -140,9 +145,11 @@ export function useAsyncQuery (collection, query, options) {
140
145
  }
141
146
 
142
147
  export function useBatchQuery$ (collection, query, _options) {
148
+ const normalizedQuery = normalizeQuery(query, 'useBatchQuery')
143
149
  const $collection = getCollectionSignal(collection, query, 'useBatchQuery')
150
+ registerBatchQueryReadinessCheck(collection, normalizedQuery)
144
151
  const options = _options ? { ..._options, ...BATCH_SUB_OPTIONS } : BATCH_SUB_OPTIONS
145
- return useSub($collection, normalizeQuery(query, 'useBatchQuery'), options)
152
+ return useSub($collection, normalizedQuery, options)
146
153
  }
147
154
 
148
155
  export function useBatchQuery (collection, query, options) {
@@ -316,3 +323,84 @@ const BATCH_SUB_OPTIONS = Object.freeze({
316
323
  // on route transitions and cause immediate reads from stale/empty local nodes.
317
324
  defer: false
318
325
  })
326
+
327
+ function getDocIdFromSignal ($doc) {
328
+ const path = typeof $doc?.path === 'function' ? $doc.path() : ''
329
+ const segments = path ? path.split('.').filter(Boolean) : []
330
+ return segments[segments.length - 1]
331
+ }
332
+
333
+ function registerBatchDocReadinessCheck (collection, id) {
334
+ if (!isCompatEnv()) return
335
+ if (!collection || id == null) return
336
+ const docSegments = [collection, id]
337
+ promiseBatcher.addCheck({
338
+ key: `doc:${collection}.${id}`,
339
+ type: 'doc',
340
+ details: `${collection}.${id}`,
341
+ isReady: () => isDocReady(docSegments),
342
+ getState: () => {
343
+ const shareDoc = getShareDoc(collection, id)
344
+ return {
345
+ raw: getRaw(docSegments),
346
+ shareDoc: shareDoc
347
+ ? {
348
+ type: shareDoc.type,
349
+ data: shareDoc.data
350
+ }
351
+ : undefined
352
+ }
353
+ }
354
+ })
355
+ }
356
+
357
+ function registerBatchQueryReadinessCheck (collection, query) {
358
+ if (!isCompatEnv()) return
359
+ if (!collection || !query || typeof query !== 'object') return
360
+ const hash = hashQuery(collection, query)
361
+ const idsSegments = [QUERIES, hash, 'ids']
362
+ promiseBatcher.addCheck({
363
+ key: `query:${hash}`,
364
+ type: 'query',
365
+ details: { collection, hash, query },
366
+ isReady: () => isQueryReady(collection, idsSegments),
367
+ getState: () => {
368
+ const ids = getRaw(idsSegments)
369
+ return {
370
+ ids,
371
+ docs: Array.isArray(ids)
372
+ ? ids.map(id => ({
373
+ id,
374
+ raw: getRaw([collection, id])
375
+ }))
376
+ : ids
377
+ }
378
+ }
379
+ })
380
+ }
381
+
382
+ function isQueryReady (collection, idsSegments) {
383
+ const ids = getRaw(idsSegments)
384
+ if (!Array.isArray(ids)) return false
385
+ for (const id of ids) {
386
+ if (!isDocReady([collection, id])) return false
387
+ }
388
+ return true
389
+ }
390
+
391
+ function isDocReady (segments) {
392
+ const rawDoc = getRaw(segments)
393
+ if (rawDoc !== undefined) return true
394
+ const [collection, id] = segments
395
+ const shareDoc = getShareDoc(collection, id)
396
+ // Missing docs should not block the batch barrier forever.
397
+ return !!(shareDoc && shareDoc.type === null && shareDoc.data == null)
398
+ }
399
+
400
+ function getShareDoc (collection, id) {
401
+ try {
402
+ return getConnection().get(collection, id)
403
+ } catch {
404
+ return undefined
405
+ }
406
+ }
package/orm/Doc.js CHANGED
@@ -6,6 +6,7 @@ import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
6
6
  import SubscriptionState from './SubscriptionState.js'
7
7
  import { getIdFieldsForSegments, injectIdFields, isPlainObject } from './idFields.js'
8
8
  import { emitModelChange, isModelEventsEnabled } from './Compat/modelEvents.js'
9
+ import { getSubscriptionGcDelay } from './subscriptionGcDelay.js'
9
10
 
10
11
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
11
12
 
@@ -103,11 +104,13 @@ class Doc {
103
104
  }
104
105
  }
105
106
 
106
- class DocSubscriptions {
107
- constructor () {
107
+ export class DocSubscriptions {
108
+ constructor (DocClass = Doc) {
109
+ this.DocClass = DocClass
108
110
  this.subCount = new Map()
109
111
  this.docs = new Map()
110
- this.fr = new FinalizationRegistry(segments => this.destroy(segments))
112
+ this.pendingDestroyTimers = new Map()
113
+ this.fr = new FinalizationRegistry(segments => this.scheduleDestroy(segments, { force: true }))
111
114
  }
112
115
 
113
116
  init ($doc) {
@@ -118,7 +121,7 @@ class DocSubscriptions {
118
121
  if (doc.initialized) return
119
122
  doc.init()
120
123
  } else {
121
- doc = new Doc(...segments)
124
+ doc = new this.DocClass(...segments)
122
125
  this.docs.set(hash, doc)
123
126
  this.fr.register($doc, segments, $doc)
124
127
  doc.init()
@@ -128,10 +131,17 @@ class DocSubscriptions {
128
131
  subscribe ($doc) {
129
132
  const segments = [...$doc[SEGMENTS]]
130
133
  const hash = hashDoc(segments)
134
+ this.cancelDestroy(hash)
131
135
  let count = this.subCount.get(hash) || 0
132
136
  count += 1
133
137
  this.subCount.set(hash, count)
134
- if (count > 1) return this.docs.get(hash)._subscribing
138
+ if (count > 1) {
139
+ const existingDoc = this.docs.get(hash)
140
+ if (existingDoc) return existingDoc._subscribing
141
+ // Recover from stale ref-count state when doc entry was already cleaned up.
142
+ count = 1
143
+ this.subCount.set(hash, count)
144
+ }
135
145
 
136
146
  this.init($doc)
137
147
  const doc = this.docs.get(hash)
@@ -152,19 +162,83 @@ class DocSubscriptions {
152
162
  this.subCount.set(hash, count)
153
163
  return
154
164
  }
165
+ this.subCount.set(hash, 0)
155
166
  this.fr.unregister($doc)
156
- await this.destroy(segments)
167
+ await this.scheduleDestroy(segments)
157
168
  }
158
169
 
159
170
  async destroy (segments) {
160
171
  const hash = hashDoc(segments)
172
+ await this.destroyByHash(hash, { force: true })
173
+ }
174
+
175
+ async clear () {
176
+ for (const entry of this.pendingDestroyTimers.values()) {
177
+ clearTimeout(entry.timer)
178
+ }
179
+ this.pendingDestroyTimers.clear()
180
+ const hashes = Array.from(this.docs.keys())
181
+ for (const hash of hashes) {
182
+ await this.destroyByHash(hash, { force: true })
183
+ }
184
+ this.subCount.clear()
185
+ }
186
+
187
+ async flushPendingDestroys () {
188
+ const entries = Array.from(this.pendingDestroyTimers.entries())
189
+ for (const [hash, entry] of entries) {
190
+ clearTimeout(entry.timer)
191
+ this.pendingDestroyTimers.delete(hash)
192
+ await this.destroyByHash(hash, { force: entry.force })
193
+ }
194
+ }
195
+
196
+ async scheduleDestroy (segments, options = {}) {
197
+ const hash = hashDoc(segments)
198
+ const delay = getSubscriptionGcDelay()
199
+ if (delay <= 0) {
200
+ await this.destroyByHash(hash, options)
201
+ return
202
+ }
203
+ const existing = this.pendingDestroyTimers.get(hash)
204
+ if (existing) {
205
+ if (options.force) existing.force = true
206
+ return
207
+ }
208
+ const timer = setTimeout(() => {
209
+ const entry = this.pendingDestroyTimers.get(hash)
210
+ if (!entry) return
211
+ this.pendingDestroyTimers.delete(hash)
212
+ this.destroyByHash(hash, { force: entry.force }).catch(ignoreDestroyError)
213
+ }, delay)
214
+ this.pendingDestroyTimers.set(hash, {
215
+ timer,
216
+ force: !!options.force
217
+ })
218
+ }
219
+
220
+ cancelDestroy (hash) {
221
+ const entry = this.pendingDestroyTimers.get(hash)
222
+ if (!entry) return
223
+ clearTimeout(entry.timer)
224
+ this.pendingDestroyTimers.delete(hash)
225
+ }
226
+
227
+ async destroyByHash (hash, options = {}) {
228
+ this.cancelDestroy(hash)
229
+ const count = this.subCount.get(hash) || 0
230
+ if (!options.force && count > 0) return
161
231
  const doc = this.docs.get(hash)
162
- if (!doc) return
232
+ if (!doc) {
233
+ this.subCount.delete(hash)
234
+ return
235
+ }
163
236
  this.subCount.delete(hash)
164
237
  // Always call unsubscribe() - if doc is in SUBSCRIBING state, the state machine
165
238
  // will queue a pending unsubscribe to execute after subscribe completes
166
239
  await doc.unsubscribe()
167
240
  if (doc.subscribed) return // Subscribed again while unsubscribing
241
+ if ((this.subCount.get(hash) || 0) > 0) return
168
242
  this.docs.delete(hash)
169
243
  }
170
244
  }
@@ -175,6 +249,8 @@ function hashDoc (segments) {
175
249
  return JSON.stringify(segments)
176
250
  }
177
251
 
252
+ function ignoreDestroyError () {}
253
+
178
254
  function emitDocOp (collection, docId, op) {
179
255
  if (!isModelEventsEnabled()) return
180
256
  const ops = Array.isArray(op) ? op : [op]
package/orm/Query.js CHANGED
@@ -8,6 +8,7 @@ import { docSubscriptions } from './Doc.js'
8
8
  import FinalizationRegistry from '../utils/MockFinalizationRegistry.js'
9
9
  import SubscriptionState from './SubscriptionState.js'
10
10
  import { getIdFieldsForSegments, injectIdFields, isPlainObject } from './idFields.js'
11
+ import { getSubscriptionGcDelay } from './subscriptionGcDelay.js'
11
12
 
12
13
  const ERROR_ON_EXCESSIVE_UNSUBSCRIBES = false
13
14
  export const COLLECTION_NAME = Symbol('query collection name')
@@ -211,13 +212,17 @@ export class QuerySubscriptions {
211
212
  this.QueryClass = QueryClass
212
213
  this.subCount = new Map()
213
214
  this.queries = new Map()
214
- this.fr = new FinalizationRegistry(({ collectionName, params }) => this.destroy(collectionName, params))
215
+ this.pendingDestroyTimers = new Map()
216
+ this.fr = new FinalizationRegistry(({ collectionName, params }) => {
217
+ this.scheduleDestroy(collectionName, params, undefined, { force: true })
218
+ })
215
219
  }
216
220
 
217
221
  subscribe ($query) {
218
222
  const collectionName = $query[COLLECTION_NAME]
219
223
  const params = JSON.parse(JSON.stringify($query[PARAMS]))
220
224
  const hash = $query[HASH]
225
+ this.cancelDestroy(hash)
221
226
  let count = this.subCount.get(hash) || 0
222
227
  count += 1
223
228
  this.subCount.set(hash, count)
@@ -252,22 +257,89 @@ export class QuerySubscriptions {
252
257
  this.subCount.set(hash, count)
253
258
  return
254
259
  }
255
- this.subCount.delete(hash)
260
+ this.subCount.set(hash, 0)
256
261
  this.fr.unregister($query)
257
- const query = this.queries.get(hash)
258
- if (!query) return
259
- await query.unsubscribe()
260
- if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
261
- this.queries.delete(hash)
262
+ await this.scheduleDestroy($query[COLLECTION_NAME], $query[PARAMS], hash)
262
263
  }
263
264
 
264
- async destroy (collectionName, params) {
265
+ async destroy (collectionName, params, options = {}) {
265
266
  const hash = hashQuery(collectionName, params)
267
+ await this.destroyByHash(hash, {
268
+ collectionName,
269
+ params,
270
+ force: options.force ?? true
271
+ })
272
+ }
273
+
274
+ async clear () {
275
+ for (const entry of this.pendingDestroyTimers.values()) {
276
+ clearTimeout(entry.timer)
277
+ }
278
+ this.pendingDestroyTimers.clear()
279
+ const hashes = Array.from(this.queries.keys())
280
+ for (const hash of hashes) {
281
+ const { collectionName, params } = parseQueryHash(hash)
282
+ await this.destroyByHash(hash, {
283
+ collectionName,
284
+ params,
285
+ force: true
286
+ })
287
+ }
288
+ this.subCount.clear()
289
+ }
290
+
291
+ async flushPendingDestroys () {
292
+ const entries = Array.from(this.pendingDestroyTimers.entries())
293
+ for (const [hash, entry] of entries) {
294
+ clearTimeout(entry.timer)
295
+ this.pendingDestroyTimers.delete(hash)
296
+ await this.destroyByHash(hash, { force: entry.force })
297
+ }
298
+ }
299
+
300
+ async scheduleDestroy (collectionName, params, hash = hashQuery(collectionName, params), options = {}) {
301
+ const delay = getSubscriptionGcDelay()
302
+ if (delay <= 0) {
303
+ await this.destroyByHash(hash, { collectionName, params, force: !!options.force })
304
+ return
305
+ }
306
+ const existing = this.pendingDestroyTimers.get(hash)
307
+ if (existing) {
308
+ if (options.force) existing.force = true
309
+ return
310
+ }
311
+ const timer = setTimeout(() => {
312
+ const entry = this.pendingDestroyTimers.get(hash)
313
+ if (!entry) return
314
+ this.pendingDestroyTimers.delete(hash)
315
+ this.destroyByHash(hash, { collectionName, params, force: entry.force }).catch(ignoreDestroyError)
316
+ }, delay)
317
+ this.pendingDestroyTimers.set(hash, {
318
+ timer,
319
+ force: !!options.force
320
+ })
321
+ }
322
+
323
+ cancelDestroy (hash) {
324
+ const entry = this.pendingDestroyTimers.get(hash)
325
+ if (!entry) return
326
+ clearTimeout(entry.timer)
327
+ this.pendingDestroyTimers.delete(hash)
328
+ }
329
+
330
+ async destroyByHash (hash, options = {}) {
331
+ this.cancelDestroy(hash)
332
+ const count = this.subCount.get(hash) || 0
333
+ if (!options.force && count > 0) return
266
334
  const query = this.queries.get(hash)
267
- if (!query) return
335
+ if (!query) {
336
+ this.subCount.delete(hash)
337
+ return
338
+ }
268
339
  this.subCount.delete(hash)
269
340
  await query.unsubscribe()
270
341
  if (query.subscribed) return // if we subscribed again while waiting for unsubscribe, we don't delete the doc
342
+ if ((this.subCount.get(hash) || 0) > 0) return
271
343
  this.queries.delete(hash)
272
344
  }
273
345
  }
@@ -322,3 +394,5 @@ const ERRORS = {
322
394
  Params: ${$query[PARAMS]}
323
395
  `
324
396
  }
397
+
398
+ function ignoreDestroyError () {}
@@ -0,0 +1,32 @@
1
+ import { isCompatEnv } from './compatEnv.js'
2
+
3
+ const DEFAULT_COMPAT_SUBSCRIPTION_GC_DELAY = 300
4
+ const DEFAULT_SUBSCRIPTION_GC_DELAY = 0
5
+
6
+ let subscriptionGcDelay = getDefaultSubscriptionGcDelay()
7
+
8
+ export function getSubscriptionGcDelay () {
9
+ return subscriptionGcDelay
10
+ }
11
+
12
+ export function setSubscriptionGcDelay (ms) {
13
+ if (ms == null) {
14
+ subscriptionGcDelay = getDefaultSubscriptionGcDelay()
15
+ return subscriptionGcDelay
16
+ }
17
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
18
+ throw Error('setSubscriptionGcDelay() expects a non-negative finite number')
19
+ }
20
+ subscriptionGcDelay = ms
21
+ return subscriptionGcDelay
22
+ }
23
+
24
+ export function getDefaultSubscriptionGcDelay () {
25
+ return isCompatEnv()
26
+ ? DEFAULT_COMPAT_SUBSCRIPTION_GC_DELAY
27
+ : DEFAULT_SUBSCRIPTION_GC_DELAY
28
+ }
29
+
30
+ export function __resetSubscriptionGcDelayForTests () {
31
+ subscriptionGcDelay = getDefaultSubscriptionGcDelay()
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teamplay",
3
- "version": "0.4.0-alpha.32",
3
+ "version": "0.4.0-alpha.34",
4
4
  "description": "Full-stack signals ORM with multiplayer",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -81,5 +81,5 @@
81
81
  ]
82
82
  },
83
83
  "license": "MIT",
84
- "gitHead": "384c14383a45af21bad2f752011db1462f7d8628"
84
+ "gitHead": "619024ea6ea3699ab461c279791a59e5a58334ab"
85
85
  }
@@ -1,5 +1,9 @@
1
1
  let active = false
2
2
  let promises = []
3
+ let checks = new Map()
4
+
5
+ const READINESS_POLL_INTERVAL_MS = 16
6
+ const READINESS_WARN_AFTER_MS = 1000
3
7
 
4
8
  export function activate () {
5
9
  active = true
@@ -10,9 +14,22 @@ export function add (promise) {
10
14
  promises.push(promise)
11
15
  }
12
16
 
17
+ export function addCheck (check) {
18
+ if (!check || typeof check.isReady !== 'function') return
19
+ const key = check.key ?? Symbol('batch-check')
20
+ checks.set(key, { ...check, key })
21
+ }
22
+
13
23
  export function getPromiseAll () {
14
- const hasPromises = promises.length > 0
15
- const result = hasPromises ? Promise.all(promises) : null
24
+ const pendingPromises = promises
25
+ const pendingChecks = Array.from(checks.values())
26
+ const hasPromises = pendingPromises.length > 0
27
+ const hasChecks = pendingChecks.length > 0
28
+ const result = !hasPromises && !hasChecks
29
+ ? null
30
+ : hasPromises || !areChecksReady(pendingChecks)
31
+ ? waitForBatchReady(pendingPromises, pendingChecks)
32
+ : null
16
33
  reset()
17
34
  return result
18
35
  }
@@ -24,4 +41,80 @@ export function isActive () {
24
41
  export function reset () {
25
42
  active = false
26
43
  promises = []
44
+ checks = new Map()
45
+ }
46
+
47
+ async function waitForBatchReady (pendingPromises, pendingChecks) {
48
+ if (pendingPromises.length > 0) await Promise.all(pendingPromises)
49
+ // Let microtasks flush after subscription promises resolve so tree writes become visible.
50
+ await Promise.resolve()
51
+ await waitForChecksReady(pendingChecks)
52
+ }
53
+
54
+ async function waitForChecksReady (pendingChecks) {
55
+ if (pendingChecks.length === 0) return
56
+ let warned = false
57
+ const startedAt = Date.now()
58
+ while (true) {
59
+ const notReadyChecks = getNotReadyChecks(pendingChecks)
60
+ if (notReadyChecks.length === 0) return
61
+ if (!warned && isDevMode() && Date.now() - startedAt >= READINESS_WARN_AFTER_MS) {
62
+ warned = true
63
+ warnAboutChecksDelay(notReadyChecks)
64
+ }
65
+ await delay(READINESS_POLL_INTERVAL_MS)
66
+ }
67
+ }
68
+
69
+ function areChecksReady (pendingChecks) {
70
+ if (pendingChecks.length === 0) return true
71
+ return getNotReadyChecks(pendingChecks).length === 0
72
+ }
73
+
74
+ function getNotReadyChecks (pendingChecks) {
75
+ const notReady = []
76
+ for (const check of pendingChecks) {
77
+ if (!isCheckReady(check)) notReady.push(check)
78
+ }
79
+ return notReady
80
+ }
81
+
82
+ function isCheckReady (check) {
83
+ try {
84
+ return !!check.isReady()
85
+ } catch (err) {
86
+ if (isThenable(err)) return false
87
+ throw err
88
+ }
89
+ }
90
+
91
+ function warnAboutChecksDelay (checks) {
92
+ const details = checks.map(check => {
93
+ let state
94
+ try {
95
+ state = typeof check.getState === 'function' ? check.getState() : undefined
96
+ } catch (err) {
97
+ state = isThenable(err) ? 'suspended' : `state-error: ${err?.message || err}`
98
+ }
99
+ return {
100
+ type: check.type || 'unknown',
101
+ key: String(check.key),
102
+ details: check.details,
103
+ state
104
+ }
105
+ })
106
+ console.warn('[teamplay] useBatch() is waiting for data materialization checks.', details)
107
+ }
108
+
109
+ function isDevMode () {
110
+ if (typeof process === 'undefined' || !process?.env) return true
111
+ return process.env.NODE_ENV !== 'production'
112
+ }
113
+
114
+ function isThenable (value) {
115
+ return !!value && typeof value.then === 'function'
116
+ }
117
+
118
+ function delay (ms) {
119
+ return new Promise(resolve => setTimeout(resolve, ms))
27
120
  }