@techninja/clearstack 0.3.27 → 0.3.29
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.
|
@@ -102,21 +102,14 @@ default values, so the model initializes cleanly:
|
|
|
102
102
|
|
|
103
103
|
```javascript
|
|
104
104
|
[store.connect]: {
|
|
105
|
-
// ✅ GOOD — returns empty object, hybrids merges with defaults
|
|
106
105
|
get: () => {
|
|
107
106
|
const raw = localStorage.getItem('appState');
|
|
108
|
-
return raw ? JSON.parse(raw) : {};
|
|
107
|
+
return raw ? JSON.parse(raw) : {}; // ✅ — {} merges with defaults
|
|
108
|
+
// return raw ? JSON.parse(raw) : undefined; // ❌ — triggers error state
|
|
109
109
|
},
|
|
110
|
-
// ❌ BAD — undefined triggers error state
|
|
111
|
-
// get: () => {
|
|
112
|
-
// const raw = localStorage.getItem('appState');
|
|
113
|
-
// return raw ? JSON.parse(raw) : undefined;
|
|
114
|
-
// },
|
|
115
110
|
}
|
|
116
111
|
```
|
|
117
112
|
|
|
118
|
-
This applies to all localStorage-backed singletons (`AppState`, `UserPrefs`).
|
|
119
|
-
|
|
120
113
|
Consume in any component:
|
|
121
114
|
|
|
122
115
|
```javascript
|
|
@@ -227,56 +220,28 @@ non-array during the pending phase.
|
|
|
227
220
|
Always guard with `Array.isArray()` before calling array methods:
|
|
228
221
|
|
|
229
222
|
```javascript
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
invalidate();
|
|
238
|
-
});
|
|
239
|
-
},
|
|
240
|
-
},
|
|
241
|
-
render: ({ items }) => html`
|
|
242
|
-
// ❌ BAD — items may not be an array yet when render fires
|
|
243
|
-
${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)} // ✅ GOOD — guard
|
|
244
|
-
before calling array methods
|
|
245
|
-
${Array.isArray(items) && items.length > 0
|
|
246
|
-
? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
|
|
247
|
-
: html``}
|
|
248
|
-
`,
|
|
249
|
-
});
|
|
223
|
+
// ❌ BAD — items may not be an array yet when render fires
|
|
224
|
+
${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)}
|
|
225
|
+
|
|
226
|
+
// ✅ GOOD — guard before calling array methods
|
|
227
|
+
${Array.isArray(items) && items.length > 0
|
|
228
|
+
? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
|
|
229
|
+
: html``}
|
|
250
230
|
```
|
|
251
231
|
|
|
252
|
-
|
|
253
|
-
The `connect` callback sets the value and calls `invalidate()`, but the
|
|
254
|
-
first render happens before that resolves.
|
|
232
|
+
The first render fires before `connect`'s async callback resolves.
|
|
255
233
|
|
|
256
234
|
#### Async Init with Child Component Props
|
|
257
235
|
|
|
258
|
-
When a parent
|
|
259
|
-
|
|
260
|
-
detection requires observing old → new. If the prop is set before the
|
|
261
|
-
child mounts, there's no "old" value to compare against.
|
|
236
|
+
When a parent sets a child prop during `connect`, the child may never
|
|
237
|
+
see the change (no old → new transition). Defer to after first render:
|
|
262
238
|
|
|
263
239
|
```javascript
|
|
264
|
-
// ❌ BAD —
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
connect: (host, _key, invalidate) => {
|
|
268
|
-
loadData(host).then(() => {
|
|
269
|
-
host.resultCount = 14;
|
|
270
|
-
invalidate(); // child mounts with 14, never sees a change
|
|
271
|
-
});
|
|
272
|
-
},
|
|
240
|
+
// ❌ BAD — child mounts with 14, never sees a change
|
|
241
|
+
connect: (host, _key, invalidate) => {
|
|
242
|
+
loadData(host).then(() => { host.resultCount = 14; invalidate(); });
|
|
273
243
|
},
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
The fix: defer the prop update to after the first render frame so the
|
|
277
|
-
child is mounted and can observe the transition:
|
|
278
244
|
|
|
279
|
-
```javascript
|
|
280
245
|
// ✅ GOOD — child mounts with default (0), then sees 0 → 14
|
|
281
246
|
connect: (host, _key, invalidate) => {
|
|
282
247
|
loadData(host).then(() => {
|
|
@@ -286,10 +251,6 @@ connect: (host, _key, invalidate) => {
|
|
|
286
251
|
},
|
|
287
252
|
```
|
|
288
253
|
|
|
289
|
-
Pre-populate external caches during `loadData` so the child has data
|
|
290
|
-
ready when the deferred update triggers. The 0 → 14 transition happens
|
|
291
|
-
within a single frame — no flicker.
|
|
292
|
-
|
|
293
254
|
---
|
|
294
255
|
|
|
295
256
|
## Routing
|
|
@@ -346,40 +307,69 @@ export default define({
|
|
|
346
307
|
|
|
347
308
|
#### Router Property Cache: Same-Value Writes Are No-Ops
|
|
348
309
|
|
|
349
|
-
The router
|
|
350
|
-
|
|
351
|
-
|
|
310
|
+
The router restores cached property values on reconnect. If `connect`
|
|
311
|
+
sets a property to the same value the cache holds, hybrids skips re-render.
|
|
312
|
+
Reset to a sentinel first:
|
|
313
|
+
|
|
314
|
+
```javascript
|
|
315
|
+
// ❌ BAD — may equal cached value → no re-render
|
|
316
|
+
host.resultCount = data.length;
|
|
317
|
+
|
|
318
|
+
// ✅ GOOD — force a change so hybrids sees 0 → 22
|
|
319
|
+
host.resultCount = 0;
|
|
320
|
+
host.resultCount = data.length;
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Important when render depends on external mutable state that the property
|
|
324
|
+
change is meant to signal.
|
|
325
|
+
|
|
326
|
+
#### Router Cache Poisons Boolean Flags Across Reconnects
|
|
352
327
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
328
|
+
When a routed view has a boolean property used as a fallback/error flag,
|
|
329
|
+
the router cache can poison it across reconnects. If the flag is ever set
|
|
330
|
+
to `true` (e.g. a failed fetch), the cached value persists. On the next
|
|
331
|
+
navigation to that view, `connect` runs but the property is already `true`
|
|
332
|
+
from cache — even though the default is `false`:
|
|
356
333
|
|
|
357
334
|
```javascript
|
|
358
|
-
// ❌ BAD — if
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
host
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
},
|
|
335
|
+
// ❌ BAD — if fallback was ever true, it stays true on reconnect
|
|
336
|
+
export default define({
|
|
337
|
+
tag: 'my-view',
|
|
338
|
+
data: {
|
|
339
|
+
value: undefined,
|
|
340
|
+
connect(host, _key, invalidate) {
|
|
341
|
+
loadData(host).then(() => invalidate());
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
fallback: false, // default is false, but cache may hold true
|
|
345
|
+
render: ({ data, fallback }) => {
|
|
346
|
+
if (fallback) return html`<p>Error</p>`; // stuck here forever
|
|
347
|
+
// ...
|
|
348
|
+
},
|
|
349
|
+
});
|
|
367
350
|
|
|
368
|
-
// ✅ GOOD — reset
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
host
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
},
|
|
351
|
+
// ✅ GOOD — reset the flag in connect before the async load
|
|
352
|
+
export default define({
|
|
353
|
+
tag: 'my-view',
|
|
354
|
+
data: {
|
|
355
|
+
value: undefined,
|
|
356
|
+
connect(host, _key, invalidate) {
|
|
357
|
+
host.fallback = false; // clear stale cache
|
|
358
|
+
loadData(host).then(() => invalidate());
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
fallback: false,
|
|
362
|
+
render: ({ data, fallback }) => {
|
|
363
|
+
if (fallback) return html`<p>Error</p>`;
|
|
364
|
+
// ...
|
|
365
|
+
},
|
|
366
|
+
});
|
|
378
367
|
```
|
|
379
368
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
369
|
+
The general rule: any boolean flag that gates render output and is set
|
|
370
|
+
by an async `connect` flow should be explicitly reset at the top of
|
|
371
|
+
`connect`. This is especially common with fetch-or-fallback patterns
|
|
372
|
+
where the fallback path sets the flag to `true` on network errors.
|
|
383
373
|
|
|
384
374
|
#### `router.backUrl()` Serializes All Parent Properties
|
|
385
375
|
|
package/package.json
CHANGED
|
@@ -102,21 +102,14 @@ default values, so the model initializes cleanly:
|
|
|
102
102
|
|
|
103
103
|
```javascript
|
|
104
104
|
[store.connect]: {
|
|
105
|
-
// ✅ GOOD — returns empty object, hybrids merges with defaults
|
|
106
105
|
get: () => {
|
|
107
106
|
const raw = localStorage.getItem('appState');
|
|
108
|
-
return raw ? JSON.parse(raw) : {};
|
|
107
|
+
return raw ? JSON.parse(raw) : {}; // ✅ — {} merges with defaults
|
|
108
|
+
// return raw ? JSON.parse(raw) : undefined; // ❌ — triggers error state
|
|
109
109
|
},
|
|
110
|
-
// ❌ BAD — undefined triggers error state
|
|
111
|
-
// get: () => {
|
|
112
|
-
// const raw = localStorage.getItem('appState');
|
|
113
|
-
// return raw ? JSON.parse(raw) : undefined;
|
|
114
|
-
// },
|
|
115
110
|
}
|
|
116
111
|
```
|
|
117
112
|
|
|
118
|
-
This applies to all localStorage-backed singletons (`AppState`, `UserPrefs`).
|
|
119
|
-
|
|
120
113
|
Consume in any component:
|
|
121
114
|
|
|
122
115
|
```javascript
|
|
@@ -227,56 +220,28 @@ non-array during the pending phase.
|
|
|
227
220
|
Always guard with `Array.isArray()` before calling array methods:
|
|
228
221
|
|
|
229
222
|
```javascript
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
invalidate();
|
|
238
|
-
});
|
|
239
|
-
},
|
|
240
|
-
},
|
|
241
|
-
render: ({ items }) => html`
|
|
242
|
-
// ❌ BAD — items may not be an array yet when render fires
|
|
243
|
-
${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)} // ✅ GOOD — guard
|
|
244
|
-
before calling array methods
|
|
245
|
-
${Array.isArray(items) && items.length > 0
|
|
246
|
-
? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
|
|
247
|
-
: html``}
|
|
248
|
-
`,
|
|
249
|
-
});
|
|
223
|
+
// ❌ BAD — items may not be an array yet when render fires
|
|
224
|
+
${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)}
|
|
225
|
+
|
|
226
|
+
// ✅ GOOD — guard before calling array methods
|
|
227
|
+
${Array.isArray(items) && items.length > 0
|
|
228
|
+
? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
|
|
229
|
+
: html``}
|
|
250
230
|
```
|
|
251
231
|
|
|
252
|
-
|
|
253
|
-
The `connect` callback sets the value and calls `invalidate()`, but the
|
|
254
|
-
first render happens before that resolves.
|
|
232
|
+
The first render fires before `connect`'s async callback resolves.
|
|
255
233
|
|
|
256
234
|
#### Async Init with Child Component Props
|
|
257
235
|
|
|
258
|
-
When a parent
|
|
259
|
-
|
|
260
|
-
detection requires observing old → new. If the prop is set before the
|
|
261
|
-
child mounts, there's no "old" value to compare against.
|
|
236
|
+
When a parent sets a child prop during `connect`, the child may never
|
|
237
|
+
see the change (no old → new transition). Defer to after first render:
|
|
262
238
|
|
|
263
239
|
```javascript
|
|
264
|
-
// ❌ BAD —
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
connect: (host, _key, invalidate) => {
|
|
268
|
-
loadData(host).then(() => {
|
|
269
|
-
host.resultCount = 14;
|
|
270
|
-
invalidate(); // child mounts with 14, never sees a change
|
|
271
|
-
});
|
|
272
|
-
},
|
|
240
|
+
// ❌ BAD — child mounts with 14, never sees a change
|
|
241
|
+
connect: (host, _key, invalidate) => {
|
|
242
|
+
loadData(host).then(() => { host.resultCount = 14; invalidate(); });
|
|
273
243
|
},
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
The fix: defer the prop update to after the first render frame so the
|
|
277
|
-
child is mounted and can observe the transition:
|
|
278
244
|
|
|
279
|
-
```javascript
|
|
280
245
|
// ✅ GOOD — child mounts with default (0), then sees 0 → 14
|
|
281
246
|
connect: (host, _key, invalidate) => {
|
|
282
247
|
loadData(host).then(() => {
|
|
@@ -286,10 +251,6 @@ connect: (host, _key, invalidate) => {
|
|
|
286
251
|
},
|
|
287
252
|
```
|
|
288
253
|
|
|
289
|
-
Pre-populate external caches during `loadData` so the child has data
|
|
290
|
-
ready when the deferred update triggers. The 0 → 14 transition happens
|
|
291
|
-
within a single frame — no flicker.
|
|
292
|
-
|
|
293
254
|
---
|
|
294
255
|
|
|
295
256
|
## Routing
|
|
@@ -346,40 +307,69 @@ export default define({
|
|
|
346
307
|
|
|
347
308
|
#### Router Property Cache: Same-Value Writes Are No-Ops
|
|
348
309
|
|
|
349
|
-
The router
|
|
350
|
-
|
|
351
|
-
|
|
310
|
+
The router restores cached property values on reconnect. If `connect`
|
|
311
|
+
sets a property to the same value the cache holds, hybrids skips re-render.
|
|
312
|
+
Reset to a sentinel first:
|
|
313
|
+
|
|
314
|
+
```javascript
|
|
315
|
+
// ❌ BAD — may equal cached value → no re-render
|
|
316
|
+
host.resultCount = data.length;
|
|
317
|
+
|
|
318
|
+
// ✅ GOOD — force a change so hybrids sees 0 → 22
|
|
319
|
+
host.resultCount = 0;
|
|
320
|
+
host.resultCount = data.length;
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Important when render depends on external mutable state that the property
|
|
324
|
+
change is meant to signal.
|
|
325
|
+
|
|
326
|
+
#### Router Cache Poisons Boolean Flags Across Reconnects
|
|
352
327
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
328
|
+
When a routed view has a boolean property used as a fallback/error flag,
|
|
329
|
+
the router cache can poison it across reconnects. If the flag is ever set
|
|
330
|
+
to `true` (e.g. a failed fetch), the cached value persists. On the next
|
|
331
|
+
navigation to that view, `connect` runs but the property is already `true`
|
|
332
|
+
from cache — even though the default is `false`:
|
|
356
333
|
|
|
357
334
|
```javascript
|
|
358
|
-
// ❌ BAD — if
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
host
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
},
|
|
335
|
+
// ❌ BAD — if fallback was ever true, it stays true on reconnect
|
|
336
|
+
export default define({
|
|
337
|
+
tag: 'my-view',
|
|
338
|
+
data: {
|
|
339
|
+
value: undefined,
|
|
340
|
+
connect(host, _key, invalidate) {
|
|
341
|
+
loadData(host).then(() => invalidate());
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
fallback: false, // default is false, but cache may hold true
|
|
345
|
+
render: ({ data, fallback }) => {
|
|
346
|
+
if (fallback) return html`<p>Error</p>`; // stuck here forever
|
|
347
|
+
// ...
|
|
348
|
+
},
|
|
349
|
+
});
|
|
367
350
|
|
|
368
|
-
// ✅ GOOD — reset
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
host
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
},
|
|
351
|
+
// ✅ GOOD — reset the flag in connect before the async load
|
|
352
|
+
export default define({
|
|
353
|
+
tag: 'my-view',
|
|
354
|
+
data: {
|
|
355
|
+
value: undefined,
|
|
356
|
+
connect(host, _key, invalidate) {
|
|
357
|
+
host.fallback = false; // clear stale cache
|
|
358
|
+
loadData(host).then(() => invalidate());
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
fallback: false,
|
|
362
|
+
render: ({ data, fallback }) => {
|
|
363
|
+
if (fallback) return html`<p>Error</p>`;
|
|
364
|
+
// ...
|
|
365
|
+
},
|
|
366
|
+
});
|
|
378
367
|
```
|
|
379
368
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
369
|
+
The general rule: any boolean flag that gates render output and is set
|
|
370
|
+
by an async `connect` flow should be explicitly reset at the top of
|
|
371
|
+
`connect`. This is especially common with fetch-or-fallback patterns
|
|
372
|
+
where the fallback path sets the flag to `true` on network errors.
|
|
383
373
|
|
|
384
374
|
#### `router.backUrl()` Serializes All Parent Properties
|
|
385
375
|
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Copies third-party ES module sources into src/vendor/ so the browser
|
|
5
|
-
* can load them directly via import map. Runs on `npm
|
|
5
|
+
* can load them directly via import map. Runs on `npm run setup`.
|
|
6
|
+
* Applies local patches after copy.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { cpSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { cpSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
10
|
import { resolve, dirname } from 'node:path';
|
|
10
11
|
import { fileURLToPath } from 'node:url';
|
|
11
12
|
|
|
@@ -23,3 +24,24 @@ for (const dep of DEPS) {
|
|
|
23
24
|
cpSync(src, dest, { recursive: true });
|
|
24
25
|
console.log(`✓ Vendored: ${dep.name} → src/vendor/${dep.name}/`);
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
// Patch: fix router bootstrap URL rewrite (hybrids#router deep-link bug)
|
|
29
|
+
const routerPath = resolve(VENDOR_DIR, 'hybrids/router.js');
|
|
30
|
+
let routerSrc = readFileSync(routerPath, 'utf8');
|
|
31
|
+
const original = ` globalThis.history.replaceState([entry], "", options.url);`;
|
|
32
|
+
const patched = ` let nestedEntry = entry;
|
|
33
|
+
while (nestedEntry.nested) nestedEntry = nestedEntry.nested;
|
|
34
|
+
const entryConfig = getConfigById(nestedEntry.id);
|
|
35
|
+
const bootstrapUrl = entryConfig && entryConfig.browserUrl
|
|
36
|
+
? entryConfig.url(nestedEntry.params, true)
|
|
37
|
+
: options.url;
|
|
38
|
+
|
|
39
|
+
globalThis.history.replaceState([entry], "", bootstrapUrl);`;
|
|
40
|
+
|
|
41
|
+
if (routerSrc.includes(original)) {
|
|
42
|
+
routerSrc = routerSrc.replace(original, patched);
|
|
43
|
+
writeFileSync(routerPath, routerSrc);
|
|
44
|
+
console.log('✓ Patched: hybrids/router.js (bootstrap deep-link URL fix)');
|
|
45
|
+
} else if (!routerSrc.includes('bootstrapUrl')) {
|
|
46
|
+
console.warn('⚠ Could not apply router patch — source changed upstream');
|
|
47
|
+
}
|