@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
- export default define({
231
- tag: 'my-view',
232
- items: {
233
- value: [],
234
- connect: (host, _key, invalidate) => {
235
- fetchItems().then((list) => {
236
- host.items = list;
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
- This applies to any property with `value: []` and an async `connect`.
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 loads data during `connect` and passes it to a child via
259
- template props, the child may never see the change. Hybrids' change
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 — resultCount set during connect, before child mounts
265
- _init: {
266
- value: false,
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 caches component property values across navigations and page
350
- reloads. When a view reconnects, its properties are restored from cache
351
- **before** `connect` callbacks run.
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
- This means if a `connect` callback loads data asynchronously and then sets
354
- a property to the same value the cache already holds, hybrids sees no
355
- change and skips the re-render:
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 router cache already has resultCount=22,
359
- // setting it to 22 again is a no-op. No re-render happens.
360
- connect: (host, _key, invalidate) => {
361
- loadFromDB(host.userId).then((data) => {
362
- populateMemoryCache(data); // side effect: fills an external object
363
- host.resultCount = data.length; // may equal cached value → no re-render
364
- invalidate();
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 to a sentinel value first, then set the real value.
369
- // Hybrids sees 0 → 22, triggers re-render after data is loaded.
370
- connect: (host, _key, invalidate) => {
371
- loadFromDB(host.userId).then((data) => {
372
- populateMemoryCache(data);
373
- host.resultCount = 0; // force a change
374
- host.resultCount = data.length; // now hybrids sees a real change
375
- invalidate();
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
- This is especially important when render depends on external mutable state
381
- (e.g. an in-memory cache object) that the property change is meant to
382
- signal. Without the reset, the component renders with stale external state.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -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
- export default define({
231
- tag: 'my-view',
232
- items: {
233
- value: [],
234
- connect: (host, _key, invalidate) => {
235
- fetchItems().then((list) => {
236
- host.items = list;
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
- This applies to any property with `value: []` and an async `connect`.
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 loads data during `connect` and passes it to a child via
259
- template props, the child may never see the change. Hybrids' change
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 — resultCount set during connect, before child mounts
265
- _init: {
266
- value: false,
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 caches component property values across navigations and page
350
- reloads. When a view reconnects, its properties are restored from cache
351
- **before** `connect` callbacks run.
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
- This means if a `connect` callback loads data asynchronously and then sets
354
- a property to the same value the cache already holds, hybrids sees no
355
- change and skips the re-render:
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 router cache already has resultCount=22,
359
- // setting it to 22 again is a no-op. No re-render happens.
360
- connect: (host, _key, invalidate) => {
361
- loadFromDB(host.userId).then((data) => {
362
- populateMemoryCache(data); // side effect: fills an external object
363
- host.resultCount = data.length; // may equal cached value → no re-render
364
- invalidate();
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 to a sentinel value first, then set the real value.
369
- // Hybrids sees 0 → 22, triggers re-render after data is loaded.
370
- connect: (host, _key, invalidate) => {
371
- loadFromDB(host.userId).then((data) => {
372
- populateMemoryCache(data);
373
- host.resultCount = 0; // force a change
374
- host.resultCount = data.length; // now hybrids sees a real change
375
- invalidate();
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
- This is especially important when render depends on external mutable state
381
- (e.g. an in-memory cache object) that the property change is meant to
382
- signal. Without the reset, the component renders with stale external state.
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 postinstall`.
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
+ }