@vobs/resource 0.1.0 → 0.3.0

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 (2) hide show
  1. package/dist/index.js +65 -24
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -5,12 +5,30 @@
5
5
  import { signal } from '@vobs/reactivity';
6
6
  import { currentOwner, createRuntimeError } from '@vobs/runtime-core';
7
7
  const defaultCacheStore = new Map();
8
- // Cross-instance concurrent deduplication per key (contract §2): dedup tables are isolated per cache instance
9
- // (an SSR per-request cache gets per-request deduplication without cross-contamination; the default cache shares one table, matching historical behavior).
8
+ const DEFAULT_CACHE_LIMIT = 100;
10
9
  const cacheInFlightTables = new WeakMap();
11
10
  let defaultKeyCounter = 0;
12
11
  /** Default key pattern (generated by a process-level counter, unstable across processes — SSR serialization must use explicit keys). */
13
12
  const DEFAULT_KEY_PATTERN = /^resource:\d+$/u;
13
+ function readCache(cache, key) {
14
+ const entry = cache.get(key);
15
+ if (cache === defaultCacheStore && entry !== undefined && !isPromiseLike(entry)) {
16
+ defaultCacheStore.delete(key);
17
+ defaultCacheStore.set(key, entry);
18
+ }
19
+ return entry;
20
+ }
21
+ function writeCache(cache, key, entry) {
22
+ cache.set(key, entry);
23
+ if (cache !== defaultCacheStore)
24
+ return;
25
+ while (defaultCacheStore.size > DEFAULT_CACHE_LIMIT) {
26
+ const oldest = defaultCacheStore.keys().next().value;
27
+ if (oldest === undefined)
28
+ return;
29
+ defaultCacheStore.delete(oldest);
30
+ }
31
+ }
14
32
  function inFlightTable(cache) {
15
33
  let table = cacheInFlightTables.get(cache);
16
34
  if (table === undefined) {
@@ -38,7 +56,7 @@ export function createResource(source, options = {}) {
38
56
  const data = signal(undefined);
39
57
  const error = signal(undefined);
40
58
  const loading = signal(false);
41
- let controller;
59
+ let activeRequest;
42
60
  let fetchToken = 0;
43
61
  let disposed = false;
44
62
  let readyResolved = false;
@@ -55,7 +73,7 @@ export function createResource(source, options = {}) {
55
73
  const applySuccess = (value, token) => {
56
74
  if (disposed || token !== fetchToken)
57
75
  return;
58
- cache.set(key, { data: value });
76
+ writeCache(cache, key, { data: value });
59
77
  data.value = value;
60
78
  error.value = undefined;
61
79
  loading.value = false;
@@ -75,11 +93,10 @@ export function createResource(source, options = {}) {
75
93
  markReady();
76
94
  };
77
95
  const run = async (token) => {
78
- controller?.abort();
79
- controller = new AbortController();
96
+ releaseActiveRequest();
80
97
  loading.value = true;
81
98
  // Sync hit (hydrated/pre-warmed cache): data is readable on the first frame of template rendering (contract §8 sync readiness).
82
- const maybeCached = cache.get(key);
99
+ const maybeCached = readCache(cache, key);
83
100
  if (maybeCached !== undefined && !isPromiseLike(maybeCached)) {
84
101
  if (disposed || token !== fetchToken)
85
102
  return;
@@ -91,7 +108,14 @@ export function createResource(source, options = {}) {
91
108
  }
92
109
  // Miss: a single await yields uniformly (a Promise awaits resolve / a sync value is wrapped in a resolved promise) —
93
110
  // if dispose or a new refetch runs during the yield, this run terminates (cancelled runs never call source, contract semantics).
94
- const cached = await Promise.resolve(maybeCached);
111
+ let cached;
112
+ try {
113
+ cached = await Promise.resolve(maybeCached);
114
+ }
115
+ catch (failure) {
116
+ applyFailure(failure, token);
117
+ return;
118
+ }
95
119
  if (disposed || token !== fetchToken)
96
120
  return;
97
121
  if (cached !== undefined) {
@@ -101,20 +125,24 @@ export function createResource(source, options = {}) {
101
125
  markReady();
102
126
  return;
103
127
  }
104
- let promise = inFlight.get(key);
105
- if (promise === undefined) {
106
- // Snapshot the signal: this.controller may have been set to undefined by dispose when the then-callback runs.
107
- const requestSignal = controller.signal;
108
- promise = Promise.resolve().then(() => source({ signal: requestSignal, key }));
109
- inFlight.set(key, promise);
110
- promise.then(() => {
111
- inFlight.delete(key);
128
+ let request = inFlight.get(key);
129
+ if (request === undefined) {
130
+ const controller = new AbortController();
131
+ const promise = Promise.resolve().then(() => source({ signal: controller.signal, key }));
132
+ request = { controller, promise, consumers: 0 };
133
+ inFlight.set(key, request);
134
+ void promise.then(() => {
135
+ if (inFlight.get(key) === request)
136
+ inFlight.delete(key);
112
137
  }, () => {
113
- inFlight.delete(key);
138
+ if (inFlight.get(key) === request)
139
+ inFlight.delete(key);
114
140
  });
115
141
  }
142
+ request.consumers += 1;
143
+ activeRequest = request;
116
144
  try {
117
- const value = await promise;
145
+ const value = (await request.promise);
118
146
  if (disposed || token !== fetchToken)
119
147
  return;
120
148
  applySuccess(value, token);
@@ -124,6 +152,23 @@ export function createResource(source, options = {}) {
124
152
  return;
125
153
  applyFailure(failure, token);
126
154
  }
155
+ finally {
156
+ if (activeRequest === request)
157
+ releaseActiveRequest();
158
+ }
159
+ };
160
+ const releaseActiveRequest = () => {
161
+ const request = activeRequest;
162
+ if (request === undefined)
163
+ return;
164
+ activeRequest = undefined;
165
+ request.consumers -= 1;
166
+ if (request.consumers > 0)
167
+ return;
168
+ if (inFlight.get(key) === request) {
169
+ inFlight.delete(key);
170
+ request.controller.abort();
171
+ }
127
172
  };
128
173
  const refetch = () => {
129
174
  if (disposed) {
@@ -150,7 +195,7 @@ export function createResource(source, options = {}) {
150
195
  if (disposed)
151
196
  return;
152
197
  data.value = next;
153
- cache.set(key, { data: next });
198
+ writeCache(cache, key, { data: next });
154
199
  error.value = undefined;
155
200
  }
156
201
  catch (failure) {
@@ -165,13 +210,9 @@ export function createResource(source, options = {}) {
165
210
  if (disposed)
166
211
  return;
167
212
  disposed = true;
168
- controller?.abort();
169
- controller = undefined;
213
+ releaseActiveRequest();
170
214
  fetchToken += 1;
171
215
  loading.value = false;
172
- // Clean the in-flight entry on cancellation: a pending promise that was aborted before source responded
173
- // must not poison later requests for the same key (a new request would hang on a dead promise).
174
- inFlight.delete(key);
175
216
  // Disposal counts as settle (SSR await on whenReady does not hang).
176
217
  markReady();
177
218
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vobs/resource",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Declarative async data resources for vobs: fetch, cancel, cache, invalidate, refetch and mutation.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -18,8 +18,8 @@
18
18
  },
19
19
  "homepage": "https://github.com/vobsjs/vobs#readme",
20
20
  "dependencies": {
21
- "@vobs/reactivity": "0.1.0",
22
- "@vobs/runtime-core": "0.1.0"
21
+ "@vobs/reactivity": "0.3.0",
22
+ "@vobs/runtime-core": "0.3.0"
23
23
  },
24
24
  "files": [
25
25
  "dist"
@@ -36,6 +36,6 @@
36
36
  "main": "./dist/index.js",
37
37
  "sideEffects": false,
38
38
  "engines": {
39
- "node": ">=20.19.0"
39
+ "node": ">=22.12.0"
40
40
  }
41
41
  }