@techninja/clearstack 0.3.8 → 0.3.9

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.
@@ -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,40 @@ 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) => { host.items = list; invalidate(); });
236
+ },
237
+ },
238
+ render: ({ items }) => html`
239
+ // ❌ BAD — items may not be an array yet when render fires
240
+ ${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)}
241
+
242
+ // ✅ GOOD — guard before calling array methods
243
+ ${Array.isArray(items) && items.length > 0
244
+ ? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
245
+ : html``}
246
+ `,
247
+ });
248
+ ```
249
+
250
+ This applies to any property with `value: []` and an async `connect`.
251
+ The `connect` callback sets the value and calls `invalidate()`, but the
252
+ first render happens before that resolves.
253
+
183
254
  ---
184
255
 
185
256
  ## Routing
@@ -234,6 +305,71 @@ export default define({
234
305
  | Guarded route | `guard: () => isAuthenticated()` |
235
306
  | Dialog overlay | `dialog: true` on the view config |
236
307
 
308
+ #### Router Property Cache: Same-Value Writes Are No-Ops
309
+
310
+ The router caches component property values across navigations and page
311
+ reloads. When a view reconnects, its properties are restored from cache
312
+ **before** `connect` callbacks run.
313
+
314
+ This means if a `connect` callback loads data asynchronously and then sets
315
+ a property to the same value the cache already holds, hybrids sees no
316
+ change and skips the re-render:
317
+
318
+ ```javascript
319
+ // ❌ BAD — if router cache already has resultCount=22,
320
+ // setting it to 22 again is a no-op. No re-render happens.
321
+ connect: (host, _key, invalidate) => {
322
+ loadFromDB(host.userId).then((data) => {
323
+ populateMemoryCache(data); // side effect: fills an external object
324
+ host.resultCount = data.length; // may equal cached value → no re-render
325
+ invalidate();
326
+ });
327
+ },
328
+
329
+ // ✅ GOOD — reset to a sentinel value first, then set the real value.
330
+ // Hybrids sees 0 → 22, triggers re-render after data is loaded.
331
+ connect: (host, _key, invalidate) => {
332
+ loadFromDB(host.userId).then((data) => {
333
+ populateMemoryCache(data);
334
+ host.resultCount = 0; // force a change
335
+ host.resultCount = data.length; // now hybrids sees a real change
336
+ invalidate();
337
+ });
338
+ },
339
+ ```
340
+
341
+ This is especially important when render depends on external mutable state
342
+ (e.g. an in-memory cache object) that the property change is meant to
343
+ signal. Without the reset, the component renders with stale external state.
344
+
345
+ #### `router.backUrl()` Serializes All Parent Properties
346
+
347
+ `router.backUrl()` encodes the parent view's property values into query
348
+ params so hybrids can restore them when navigating back. If the parent has
349
+ properties holding complex objects (arrays of records, parsed data, etc.),
350
+ the URL becomes enormous — potentially megabytes — and the browser locks
351
+ up just rendering the `<a>` element.
352
+
353
+ The `connect: () => {}` no-op prevents hybrids from *observing* a property,
354
+ but the router still serializes it. The only way to fully exclude a
355
+ property from URL serialization is to not define it on the routed view at
356
+ all (use a module-level variable or a separate store).
357
+
358
+ For child views that don't need to restore specific parent state, use a
359
+ direct href instead of `router.backUrl()`:
360
+
361
+ ```javascript
362
+ // ❌ BAD — serializes ALL parent properties into the href
363
+ // If parent has a 700K-item array, the URL is megabytes
364
+ html`<a href="${router.backUrl()}">← Back</a>`
365
+
366
+ // ✅ GOOD — direct link, no serialization
367
+ html`<a href="/dashboard">← Back</a>`
368
+ ```
369
+
370
+ Use `router.backUrl()` only when the parent view has simple scalar
371
+ properties (strings, numbers, booleans) that are cheap to serialize.
372
+
237
373
  ---
238
374
 
239
375
  ## 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
- * Run the full spec compliance check (used by clearstack CLI).
67
- * @param {string} projectDir
68
- * @param {string} [scope]
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 === 'code') {
75
- if (!checkFileLines(projectDir, cfg.codeExt, cfg.codeMax, cfg.ignore, `Code (max ${cfg.codeMax} lines)`))
117
+ if (scope) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -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,40 @@ 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) => { host.items = list; invalidate(); });
236
+ },
237
+ },
238
+ render: ({ items }) => html`
239
+ // ❌ BAD — items may not be an array yet when render fires
240
+ ${items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)}
241
+
242
+ // ✅ GOOD — guard before calling array methods
243
+ ${Array.isArray(items) && items.length > 0
244
+ ? items.filter((i) => i.active).map((i) => html`<span>${i.name}</span>`)
245
+ : html``}
246
+ `,
247
+ });
248
+ ```
249
+
250
+ This applies to any property with `value: []` and an async `connect`.
251
+ The `connect` callback sets the value and calls `invalidate()`, but the
252
+ first render happens before that resolves.
253
+
183
254
  ---
184
255
 
185
256
  ## Routing
@@ -234,6 +305,71 @@ export default define({
234
305
  | Guarded route | `guard: () => isAuthenticated()` |
235
306
  | Dialog overlay | `dialog: true` on the view config |
236
307
 
308
+ #### Router Property Cache: Same-Value Writes Are No-Ops
309
+
310
+ The router caches component property values across navigations and page
311
+ reloads. When a view reconnects, its properties are restored from cache
312
+ **before** `connect` callbacks run.
313
+
314
+ This means if a `connect` callback loads data asynchronously and then sets
315
+ a property to the same value the cache already holds, hybrids sees no
316
+ change and skips the re-render:
317
+
318
+ ```javascript
319
+ // ❌ BAD — if router cache already has resultCount=22,
320
+ // setting it to 22 again is a no-op. No re-render happens.
321
+ connect: (host, _key, invalidate) => {
322
+ loadFromDB(host.userId).then((data) => {
323
+ populateMemoryCache(data); // side effect: fills an external object
324
+ host.resultCount = data.length; // may equal cached value → no re-render
325
+ invalidate();
326
+ });
327
+ },
328
+
329
+ // ✅ GOOD — reset to a sentinel value first, then set the real value.
330
+ // Hybrids sees 0 → 22, triggers re-render after data is loaded.
331
+ connect: (host, _key, invalidate) => {
332
+ loadFromDB(host.userId).then((data) => {
333
+ populateMemoryCache(data);
334
+ host.resultCount = 0; // force a change
335
+ host.resultCount = data.length; // now hybrids sees a real change
336
+ invalidate();
337
+ });
338
+ },
339
+ ```
340
+
341
+ This is especially important when render depends on external mutable state
342
+ (e.g. an in-memory cache object) that the property change is meant to
343
+ signal. Without the reset, the component renders with stale external state.
344
+
345
+ #### `router.backUrl()` Serializes All Parent Properties
346
+
347
+ `router.backUrl()` encodes the parent view's property values into query
348
+ params so hybrids can restore them when navigating back. If the parent has
349
+ properties holding complex objects (arrays of records, parsed data, etc.),
350
+ the URL becomes enormous — potentially megabytes — and the browser locks
351
+ up just rendering the `<a>` element.
352
+
353
+ The `connect: () => {}` no-op prevents hybrids from *observing* a property,
354
+ but the router still serializes it. The only way to fully exclude a
355
+ property from URL serialization is to not define it on the routed view at
356
+ all (use a module-level variable or a separate store).
357
+
358
+ For child views that don't need to restore specific parent state, use a
359
+ direct href instead of `router.backUrl()`:
360
+
361
+ ```javascript
362
+ // ❌ BAD — serializes ALL parent properties into the href
363
+ // If parent has a 700K-item array, the URL is megabytes
364
+ html`<a href="${router.backUrl()}">← Back</a>`
365
+
366
+ // ✅ GOOD — direct link, no serialization
367
+ html`<a href="/dashboard">← Back</a>`
368
+ ```
369
+
370
+ Use `router.backUrl()` only when the parent view has simple scalar
371
+ properties (strings, numbers, booleans) that are cheap to serialize.
372
+
237
373
  ---
238
374
 
239
375
  ## Unified App State