@techninja/clearstack 0.3.8 → 0.3.10
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/bin/cli.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Usage:
|
|
6
6
|
* clearstack init [-y] [--mode fullstack|static] [--port 3000]
|
|
7
7
|
* clearstack update
|
|
8
|
-
* clearstack check [code|docs]
|
|
8
|
+
* clearstack check [code|docs|imports|lint|lint es|format|types|audit|all]
|
|
9
9
|
* clearstack → interactive menu
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -25,16 +25,21 @@ const yes = args.includes('-y') || args.includes('--yes');
|
|
|
25
25
|
|
|
26
26
|
/** Show interactive menu. */
|
|
27
27
|
async function interactive() {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
28
|
+
try {
|
|
29
|
+
const { select } = await import('@inquirer/prompts');
|
|
30
|
+
const action = await select({
|
|
31
|
+
message: 'clearstack — what do you want to do?',
|
|
32
|
+
choices: [
|
|
33
|
+
{ name: 'Initialize a new project', value: 'init' },
|
|
34
|
+
{ name: 'Update spec docs + configs', value: 'update' },
|
|
35
|
+
{ name: 'Run spec compliance check', value: 'check' },
|
|
36
|
+
],
|
|
37
|
+
});
|
|
38
|
+
await run(action);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e?.name === 'ExitPromptError') process.exit(0);
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/**
|
|
@@ -49,9 +54,9 @@ async function run(action) {
|
|
|
49
54
|
const { update } = await import('../lib/update.js');
|
|
50
55
|
await update(PKG_ROOT);
|
|
51
56
|
} else if (action === 'check') {
|
|
52
|
-
const
|
|
57
|
+
const subs = args.filter((a) => a !== cmd && !a.startsWith('-'));
|
|
53
58
|
const { check } = await import('../lib/check.js');
|
|
54
|
-
await check(process.cwd(),
|
|
59
|
+
await check(process.cwd(), subs.join(' ') || undefined);
|
|
55
60
|
} else {
|
|
56
61
|
console.log('Usage: clearstack [init|update|check] [-y]');
|
|
57
62
|
}
|
package/docs/CONVENTIONS.md
CHANGED
|
@@ -117,15 +117,15 @@ relative paths like `../../../utils/foo.js`.
|
|
|
117
117
|
|
|
118
118
|
### Available Prefixes
|
|
119
119
|
|
|
120
|
-
| Prefix
|
|
121
|
-
|
|
|
122
|
-
| `#store/`
|
|
123
|
-
| `#utils/`
|
|
124
|
-
| `#atoms/`
|
|
125
|
-
| `#molecules/`
|
|
126
|
-
| `#organisms/`
|
|
127
|
-
| `#templates/`
|
|
128
|
-
| `#pages/`
|
|
120
|
+
| Prefix | Resolves to |
|
|
121
|
+
| ------------- | ------------------------ |
|
|
122
|
+
| `#store/` | `/store/` |
|
|
123
|
+
| `#utils/` | `/utils/` |
|
|
124
|
+
| `#atoms/` | `/components/atoms/` |
|
|
125
|
+
| `#molecules/` | `/components/molecules/` |
|
|
126
|
+
| `#organisms/` | `/components/organisms/` |
|
|
127
|
+
| `#templates/` | `/components/templates/` |
|
|
128
|
+
| `#pages/` | `/pages/` |
|
|
129
129
|
|
|
130
130
|
### Rules
|
|
131
131
|
|
|
@@ -33,6 +33,43 @@ export default define({
|
|
|
33
33
|
|
|
34
34
|
Local state resets when the component disconnects from the DOM.
|
|
35
35
|
|
|
36
|
+
#### Property Defaults: Never Use `undefined` or `null`
|
|
37
|
+
|
|
38
|
+
Hybrids infers a property's type from its initial value. The router's cache
|
|
39
|
+
observer calls `.toString()` on property values during state transitions.
|
|
40
|
+
If a property is defined as `undefined` or later set to `null`, this crashes:
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
// ❌ BAD — undefined has no type info, null crashes .toString()
|
|
44
|
+
export default define({
|
|
45
|
+
tag: 'my-view',
|
|
46
|
+
_data: undefined, // no type info for hybrids to work with
|
|
47
|
+
// ...
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Later in a handler:
|
|
51
|
+
host._data = null; // TypeError: Cannot read properties of null (reading 'toString')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Always provide a typed default value, and reset to the same type:
|
|
55
|
+
|
|
56
|
+
```javascript
|
|
57
|
+
// ✅ GOOD — array default, connect no-op prevents hybrids from observing it
|
|
58
|
+
export default define({
|
|
59
|
+
tag: 'my-view',
|
|
60
|
+
_data: { value: [], connect: () => {} },
|
|
61
|
+
// ...
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Later in a handler:
|
|
65
|
+
host._data = []; // safe — same type as default
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `connect: () => {}` no-op prevents hybrids from doing anything special
|
|
69
|
+
with the property — it's just internal state storage. This pattern is useful
|
|
70
|
+
for "private" properties that hold transient data (parsed results, file
|
|
71
|
+
contents, etc.) that shouldn't participate in the reactive render cycle.
|
|
72
|
+
|
|
36
73
|
### Shared State via Store
|
|
37
74
|
|
|
38
75
|
For state shared across components or persisted beyond a component's lifetime,
|
|
@@ -180,6 +217,42 @@ tasks.map((task) =>
|
|
|
180
217
|
This is especially important after batch operations (e.g. drag reorder)
|
|
181
218
|
where multiple items are invalidated simultaneously.
|
|
182
219
|
|
|
220
|
+
#### Async `connect` on Array Properties
|
|
221
|
+
|
|
222
|
+
When a plain array property uses `connect` to load data asynchronously,
|
|
223
|
+
the render function fires **before** the async load completes. Even though
|
|
224
|
+
the default `value` is `[]`, hybrids may resolve the property as a
|
|
225
|
+
non-array during the pending phase.
|
|
226
|
+
|
|
227
|
+
Always guard with `Array.isArray()` before calling array methods:
|
|
228
|
+
|
|
229
|
+
```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
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
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.
|
|
255
|
+
|
|
183
256
|
---
|
|
184
257
|
|
|
185
258
|
## Routing
|
|
@@ -234,6 +307,71 @@ export default define({
|
|
|
234
307
|
| Guarded route | `guard: () => isAuthenticated()` |
|
|
235
308
|
| Dialog overlay | `dialog: true` on the view config |
|
|
236
309
|
|
|
310
|
+
#### Router Property Cache: Same-Value Writes Are No-Ops
|
|
311
|
+
|
|
312
|
+
The router caches component property values across navigations and page
|
|
313
|
+
reloads. When a view reconnects, its properties are restored from cache
|
|
314
|
+
**before** `connect` callbacks run.
|
|
315
|
+
|
|
316
|
+
This means if a `connect` callback loads data asynchronously and then sets
|
|
317
|
+
a property to the same value the cache already holds, hybrids sees no
|
|
318
|
+
change and skips the re-render:
|
|
319
|
+
|
|
320
|
+
```javascript
|
|
321
|
+
// ❌ BAD — if router cache already has resultCount=22,
|
|
322
|
+
// setting it to 22 again is a no-op. No re-render happens.
|
|
323
|
+
connect: (host, _key, invalidate) => {
|
|
324
|
+
loadFromDB(host.userId).then((data) => {
|
|
325
|
+
populateMemoryCache(data); // side effect: fills an external object
|
|
326
|
+
host.resultCount = data.length; // may equal cached value → no re-render
|
|
327
|
+
invalidate();
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// ✅ GOOD — reset to a sentinel value first, then set the real value.
|
|
332
|
+
// Hybrids sees 0 → 22, triggers re-render after data is loaded.
|
|
333
|
+
connect: (host, _key, invalidate) => {
|
|
334
|
+
loadFromDB(host.userId).then((data) => {
|
|
335
|
+
populateMemoryCache(data);
|
|
336
|
+
host.resultCount = 0; // force a change
|
|
337
|
+
host.resultCount = data.length; // now hybrids sees a real change
|
|
338
|
+
invalidate();
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
This is especially important when render depends on external mutable state
|
|
344
|
+
(e.g. an in-memory cache object) that the property change is meant to
|
|
345
|
+
signal. Without the reset, the component renders with stale external state.
|
|
346
|
+
|
|
347
|
+
#### `router.backUrl()` Serializes All Parent Properties
|
|
348
|
+
|
|
349
|
+
`router.backUrl()` encodes the parent view's property values into query
|
|
350
|
+
params so hybrids can restore them when navigating back. If the parent has
|
|
351
|
+
properties holding complex objects (arrays of records, parsed data, etc.),
|
|
352
|
+
the URL becomes enormous — potentially megabytes — and the browser locks
|
|
353
|
+
up just rendering the `<a>` element.
|
|
354
|
+
|
|
355
|
+
The `connect: () => {}` no-op prevents hybrids from _observing_ a property,
|
|
356
|
+
but the router still serializes it. The only way to fully exclude a
|
|
357
|
+
property from URL serialization is to not define it on the routed view at
|
|
358
|
+
all (use a module-level variable or a separate store).
|
|
359
|
+
|
|
360
|
+
For child views that don't need to restore specific parent state, use a
|
|
361
|
+
direct href instead of `router.backUrl()`:
|
|
362
|
+
|
|
363
|
+
```javascript
|
|
364
|
+
// ❌ BAD — serializes ALL parent properties into the href
|
|
365
|
+
// If parent has a 700K-item array, the URL is megabytes
|
|
366
|
+
html`<a href="${router.backUrl()}">← Back</a>`;
|
|
367
|
+
|
|
368
|
+
// ✅ GOOD — direct link, no serialization
|
|
369
|
+
html`<a href="/dashboard">← Back</a>`;
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Use `router.backUrl()` only when the parent view has simple scalar
|
|
373
|
+
properties (strings, numbers, booleans) that are cheap to serialize.
|
|
374
|
+
|
|
237
375
|
---
|
|
238
376
|
|
|
239
377
|
## Unified App State
|
package/lib/check.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spec compliance checker — config, commands, and orchestration.
|
|
3
|
-
* Core utilities live in spec-utils.js.
|
|
4
3
|
* @module lib/check
|
|
5
4
|
*/
|
|
6
5
|
|
|
@@ -62,43 +61,74 @@ function buildCmds(runner) {
|
|
|
62
61
|
/** @deprecated Use buildCmds() instead — kept for backward compat. */
|
|
63
62
|
export const CMDS = buildCmds('npx');
|
|
64
63
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
/** @typedef {{ key: string, name: string, parent?: string, run: () => boolean }} Check */
|
|
65
|
+
|
|
66
|
+
/** Build the unified checks array. Children have a `parent` key. */
|
|
67
|
+
export function buildChecks(dir, cfg, cmds) {
|
|
68
|
+
const js = () => countFiles(dir, ['.js'], cfg.ignore);
|
|
69
|
+
const css = () => countFiles(dir, ['.css'], cfg.ignore);
|
|
70
|
+
const md = () => countFiles(dir, ['.md'], cfg.ignore);
|
|
71
|
+
return [
|
|
72
|
+
{ key: 'code', name: `Code (max ${cfg.codeMax} lines)`,
|
|
73
|
+
run: () => checkFileLines(dir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`) },
|
|
74
|
+
{ key: 'docs', name: `Docs (max ${cfg.docsMax} lines)`,
|
|
75
|
+
run: () => checkFileLines(dir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`) },
|
|
76
|
+
{ key: 'imports', name: 'Import map aliases (no ../ imports)',
|
|
77
|
+
run: () => checkImports(dir, cfg.ignore, 'Import map aliases (no ../ imports)') },
|
|
78
|
+
{ key: 'es', name: 'ESLint', parent: 'lint',
|
|
79
|
+
run: () => runCmd('ESLint', cmds.lint, dir, `${js()} files`) },
|
|
80
|
+
{ key: 'css', name: 'Stylelint', parent: 'lint',
|
|
81
|
+
run: () => runCmd('Stylelint', cmds.stylelint, dir, `${css()} files`) },
|
|
82
|
+
{ key: 'md', name: 'Markdown lint', parent: 'lint',
|
|
83
|
+
run: () => runCmd('Markdown', cmds.mdlint, dir, `${md()} files`) },
|
|
84
|
+
{ key: 'prettier', name: 'Prettier', parent: 'format',
|
|
85
|
+
run: () => runCmd('Prettier', cmds.prettier, dir, `${js()} files`) },
|
|
86
|
+
{ key: 'types', name: 'JSDoc types (tsc --checkJs)',
|
|
87
|
+
run: () => runCmd('JSDoc types', cmds.types, dir, `${js()} files`) },
|
|
88
|
+
{ key: 'audit', name: 'Security audit',
|
|
89
|
+
run: () => runCmd('Security audit', cmds.audit, dir) },
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Resolve a scope like 'lint', 'lint es', or 'code' to runnable check(s). */
|
|
94
|
+
export function resolveChecks(checks, scope) {
|
|
95
|
+
const [first, second] = scope.split(/\s+/);
|
|
96
|
+
if (second) {
|
|
97
|
+
const match = checks.find((c) => c.parent === first && c.key === second);
|
|
98
|
+
return match ? [match] : null;
|
|
99
|
+
}
|
|
100
|
+
const children = checks.filter((c) => c.parent === first);
|
|
101
|
+
if (children.length) return children;
|
|
102
|
+
const exact = checks.find((c) => c.key === first && !c.parent);
|
|
103
|
+
return exact ? [exact] : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** All unique parent keys. */
|
|
107
|
+
export function parentKeys(checks) {
|
|
108
|
+
return [...new Set(checks.filter((c) => c.parent).map((c) => c.parent))];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Run the full spec compliance check (used by clearstack CLI). */
|
|
70
112
|
export async function check(projectDir, scope) {
|
|
71
113
|
const cfg = loadConfig(projectDir);
|
|
72
114
|
const cmds = buildCmds(detectRunner(projectDir));
|
|
115
|
+
const checks = buildChecks(projectDir, cfg, cmds);
|
|
73
116
|
|
|
74
|
-
if (scope
|
|
75
|
-
|
|
117
|
+
if (scope && scope !== 'all') {
|
|
118
|
+
const matched = resolveChecks(checks, scope);
|
|
119
|
+
if (!matched) {
|
|
120
|
+
console.log(`Unknown check: ${scope}`);
|
|
121
|
+
const tops = checks.filter((c) => !c.parent).map((c) => c.key);
|
|
122
|
+
console.log(`Available: ${[...tops, ...parentKeys(checks)].join(', ')}`);
|
|
76
123
|
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
const ok = matched.every((c) => c.run());
|
|
126
|
+
if (!ok) process.exit(1);
|
|
77
127
|
return;
|
|
78
128
|
}
|
|
79
|
-
if (scope === 'docs') {
|
|
80
|
-
if (!checkFileLines(projectDir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`))
|
|
81
|
-
process.exit(1);
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const jsFiles = countFiles(projectDir, ['.js'], cfg.ignore);
|
|
86
|
-
const cssFiles = countFiles(projectDir, ['.css'], cfg.ignore);
|
|
87
|
-
const mdFiles = countFiles(projectDir, ['.md'], cfg.ignore);
|
|
88
129
|
|
|
89
130
|
console.log('Running spec compliance check...\n');
|
|
90
|
-
const results =
|
|
91
|
-
checkFileLines(projectDir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`),
|
|
92
|
-
checkFileLines(projectDir, cfg.docsExt, cfg.docsMax, cfg.ignore, `Docs (max ${cfg.docsMax} lines)`),
|
|
93
|
-
checkImports(projectDir, cfg.ignore, 'Import map aliases (no ../ imports)'),
|
|
94
|
-
runCmd('ESLint', cmds.lint, projectDir, `${jsFiles} files`),
|
|
95
|
-
runCmd('Stylelint', cmds.stylelint, projectDir, `${cssFiles} files`),
|
|
96
|
-
runCmd('Prettier', cmds.prettier, projectDir, `${jsFiles} files`),
|
|
97
|
-
runCmd('Markdown', cmds.mdlint, projectDir, `${mdFiles} files`),
|
|
98
|
-
runCmd('JSDoc types', cmds.types, projectDir, `${jsFiles} files`),
|
|
99
|
-
runCmd('Security audit', cmds.audit, projectDir),
|
|
100
|
-
];
|
|
101
|
-
|
|
131
|
+
const results = checks.map((c) => c.run());
|
|
102
132
|
const passed = results.filter(Boolean).length;
|
|
103
133
|
console.log(`\n${'='.repeat(40)}`);
|
|
104
134
|
console.log(`${passed}/${results.length} checks passed.`);
|
package/package.json
CHANGED
|
@@ -117,15 +117,15 @@ relative paths like `../../../utils/foo.js`.
|
|
|
117
117
|
|
|
118
118
|
### Available Prefixes
|
|
119
119
|
|
|
120
|
-
| Prefix
|
|
121
|
-
|
|
|
122
|
-
| `#store/`
|
|
123
|
-
| `#utils/`
|
|
124
|
-
| `#atoms/`
|
|
125
|
-
| `#molecules/`
|
|
126
|
-
| `#organisms/`
|
|
127
|
-
| `#templates/`
|
|
128
|
-
| `#pages/`
|
|
120
|
+
| Prefix | Resolves to |
|
|
121
|
+
| ------------- | ------------------------ |
|
|
122
|
+
| `#store/` | `/store/` |
|
|
123
|
+
| `#utils/` | `/utils/` |
|
|
124
|
+
| `#atoms/` | `/components/atoms/` |
|
|
125
|
+
| `#molecules/` | `/components/molecules/` |
|
|
126
|
+
| `#organisms/` | `/components/organisms/` |
|
|
127
|
+
| `#templates/` | `/components/templates/` |
|
|
128
|
+
| `#pages/` | `/pages/` |
|
|
129
129
|
|
|
130
130
|
### Rules
|
|
131
131
|
|
|
@@ -33,6 +33,43 @@ export default define({
|
|
|
33
33
|
|
|
34
34
|
Local state resets when the component disconnects from the DOM.
|
|
35
35
|
|
|
36
|
+
#### Property Defaults: Never Use `undefined` or `null`
|
|
37
|
+
|
|
38
|
+
Hybrids infers a property's type from its initial value. The router's cache
|
|
39
|
+
observer calls `.toString()` on property values during state transitions.
|
|
40
|
+
If a property is defined as `undefined` or later set to `null`, this crashes:
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
// ❌ BAD — undefined has no type info, null crashes .toString()
|
|
44
|
+
export default define({
|
|
45
|
+
tag: 'my-view',
|
|
46
|
+
_data: undefined, // no type info for hybrids to work with
|
|
47
|
+
// ...
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Later in a handler:
|
|
51
|
+
host._data = null; // TypeError: Cannot read properties of null (reading 'toString')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Always provide a typed default value, and reset to the same type:
|
|
55
|
+
|
|
56
|
+
```javascript
|
|
57
|
+
// ✅ GOOD — array default, connect no-op prevents hybrids from observing it
|
|
58
|
+
export default define({
|
|
59
|
+
tag: 'my-view',
|
|
60
|
+
_data: { value: [], connect: () => {} },
|
|
61
|
+
// ...
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Later in a handler:
|
|
65
|
+
host._data = []; // safe — same type as default
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The `connect: () => {}` no-op prevents hybrids from doing anything special
|
|
69
|
+
with the property — it's just internal state storage. This pattern is useful
|
|
70
|
+
for "private" properties that hold transient data (parsed results, file
|
|
71
|
+
contents, etc.) that shouldn't participate in the reactive render cycle.
|
|
72
|
+
|
|
36
73
|
### Shared State via Store
|
|
37
74
|
|
|
38
75
|
For state shared across components or persisted beyond a component's lifetime,
|
|
@@ -180,6 +217,42 @@ tasks.map((task) =>
|
|
|
180
217
|
This is especially important after batch operations (e.g. drag reorder)
|
|
181
218
|
where multiple items are invalidated simultaneously.
|
|
182
219
|
|
|
220
|
+
#### Async `connect` on Array Properties
|
|
221
|
+
|
|
222
|
+
When a plain array property uses `connect` to load data asynchronously,
|
|
223
|
+
the render function fires **before** the async load completes. Even though
|
|
224
|
+
the default `value` is `[]`, hybrids may resolve the property as a
|
|
225
|
+
non-array during the pending phase.
|
|
226
|
+
|
|
227
|
+
Always guard with `Array.isArray()` before calling array methods:
|
|
228
|
+
|
|
229
|
+
```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
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
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.
|
|
255
|
+
|
|
183
256
|
---
|
|
184
257
|
|
|
185
258
|
## Routing
|
|
@@ -234,6 +307,71 @@ export default define({
|
|
|
234
307
|
| Guarded route | `guard: () => isAuthenticated()` |
|
|
235
308
|
| Dialog overlay | `dialog: true` on the view config |
|
|
236
309
|
|
|
310
|
+
#### Router Property Cache: Same-Value Writes Are No-Ops
|
|
311
|
+
|
|
312
|
+
The router caches component property values across navigations and page
|
|
313
|
+
reloads. When a view reconnects, its properties are restored from cache
|
|
314
|
+
**before** `connect` callbacks run.
|
|
315
|
+
|
|
316
|
+
This means if a `connect` callback loads data asynchronously and then sets
|
|
317
|
+
a property to the same value the cache already holds, hybrids sees no
|
|
318
|
+
change and skips the re-render:
|
|
319
|
+
|
|
320
|
+
```javascript
|
|
321
|
+
// ❌ BAD — if router cache already has resultCount=22,
|
|
322
|
+
// setting it to 22 again is a no-op. No re-render happens.
|
|
323
|
+
connect: (host, _key, invalidate) => {
|
|
324
|
+
loadFromDB(host.userId).then((data) => {
|
|
325
|
+
populateMemoryCache(data); // side effect: fills an external object
|
|
326
|
+
host.resultCount = data.length; // may equal cached value → no re-render
|
|
327
|
+
invalidate();
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// ✅ GOOD — reset to a sentinel value first, then set the real value.
|
|
332
|
+
// Hybrids sees 0 → 22, triggers re-render after data is loaded.
|
|
333
|
+
connect: (host, _key, invalidate) => {
|
|
334
|
+
loadFromDB(host.userId).then((data) => {
|
|
335
|
+
populateMemoryCache(data);
|
|
336
|
+
host.resultCount = 0; // force a change
|
|
337
|
+
host.resultCount = data.length; // now hybrids sees a real change
|
|
338
|
+
invalidate();
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
This is especially important when render depends on external mutable state
|
|
344
|
+
(e.g. an in-memory cache object) that the property change is meant to
|
|
345
|
+
signal. Without the reset, the component renders with stale external state.
|
|
346
|
+
|
|
347
|
+
#### `router.backUrl()` Serializes All Parent Properties
|
|
348
|
+
|
|
349
|
+
`router.backUrl()` encodes the parent view's property values into query
|
|
350
|
+
params so hybrids can restore them when navigating back. If the parent has
|
|
351
|
+
properties holding complex objects (arrays of records, parsed data, etc.),
|
|
352
|
+
the URL becomes enormous — potentially megabytes — and the browser locks
|
|
353
|
+
up just rendering the `<a>` element.
|
|
354
|
+
|
|
355
|
+
The `connect: () => {}` no-op prevents hybrids from _observing_ a property,
|
|
356
|
+
but the router still serializes it. The only way to fully exclude a
|
|
357
|
+
property from URL serialization is to not define it on the routed view at
|
|
358
|
+
all (use a module-level variable or a separate store).
|
|
359
|
+
|
|
360
|
+
For child views that don't need to restore specific parent state, use a
|
|
361
|
+
direct href instead of `router.backUrl()`:
|
|
362
|
+
|
|
363
|
+
```javascript
|
|
364
|
+
// ❌ BAD — serializes ALL parent properties into the href
|
|
365
|
+
// If parent has a 700K-item array, the URL is megabytes
|
|
366
|
+
html`<a href="${router.backUrl()}">← Back</a>`;
|
|
367
|
+
|
|
368
|
+
// ✅ GOOD — direct link, no serialization
|
|
369
|
+
html`<a href="/dashboard">← Back</a>`;
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Use `router.backUrl()` only when the parent view has simple scalar
|
|
373
|
+
properties (strings, numbers, booleans) that are cheap to serialize.
|
|
374
|
+
|
|
237
375
|
---
|
|
238
376
|
|
|
239
377
|
## Unified App State
|