@techninja/clearstack 0.3.7 → 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.
@@ -109,6 +109,50 @@ render: ({ items }) => html`
109
109
 
110
110
  ---
111
111
 
112
+ ## Import Map Aliases
113
+
114
+ All cross-directory imports in browser-facing code use `#prefix/` aliases
115
+ defined in the import map (`src/index.html`). This eliminates fragile
116
+ relative paths like `../../../utils/foo.js`.
117
+
118
+ ### Available Prefixes
119
+
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
+
130
+ ### Rules
131
+
132
+ - **No `../` imports.** The spec check (`npm run spec`) flags any `../`
133
+ in browser-facing JS. Use a `#prefix/` alias instead.
134
+ - **Same-directory `./` is fine.** `index.js` re-exports and co-located
135
+ files use `./` naturally.
136
+ - **Server/API files are exempt.** `src/api/` and `src/server.js` run in
137
+ Node, not the browser — they use `./` relative imports.
138
+ - **Test files are exempt.** `*.test.js` files may use relative paths.
139
+ - **jsconfig.json mirrors the import map.** The `paths` entries in
140
+ `.configs/jsconfig.json` let `tsc --checkJs` resolve `#prefix/` imports.
141
+
142
+ ### Examples
143
+
144
+ ```javascript
145
+ // ✅ Good — clear, refactor-safe
146
+ import AppState from '#store/AppState.js';
147
+ import { formatDate } from '#utils/formatDate.js';
148
+ import '#atoms/app-badge/app-badge.js';
149
+
150
+ // ❌ Bad — fragile, hard to read
151
+ import AppState from '../../../store/AppState.js';
152
+ ```
153
+
154
+ ---
155
+
112
156
  ## Error Handling
113
157
 
114
158
  Errors are handled **at the boundary where they are actionable.** Each layer
@@ -193,8 +237,8 @@ state: store(AppState),
193
237
  // In src/utils/filterActive.js
194
238
  export const filterActive = (items) => items.filter(i => i.active);
195
239
 
196
- // In component
197
- import { filterActive } from '../../utils/filterActive.js';
240
+ // In component — use import map alias, never ../
241
+ import { filterActive } from '#utils/filterActive.js';
198
242
  render: ({ items }) => html`<ul>${filterActive(items).map(...)}</ul>`,
199
243
  ```
200
244
 
@@ -121,7 +121,14 @@ declared in `src/index.html`.
121
121
  <script type="importmap">
122
122
  {
123
123
  "imports": {
124
- "hybrids": "/vendor/hybrids/index.js"
124
+ "hybrids": "/vendor/hybrids/index.js",
125
+ "#store/": "/store/",
126
+ "#utils/": "/utils/",
127
+ "#atoms/": "/components/atoms/",
128
+ "#molecules/": "/components/molecules/",
129
+ "#organisms/": "/components/organisms/",
130
+ "#templates/": "/components/templates/",
131
+ "#pages/": "/pages/"
125
132
  }
126
133
  }
127
134
  </script>
@@ -139,6 +146,8 @@ declared in `src/index.html`.
139
146
  2. Import map resolves `'hybrids'` → `/vendor/hybrids/index.js`.
140
147
  3. Server serves the file from `src/vendor/hybrids/index.js`.
141
148
  4. Hybrids' internal imports use relative paths — they resolve naturally.
149
+ 5. `#prefix/` entries resolve cross-directory app imports without `../`.
150
+ For example, `import '#utils/formatDate.js'` resolves to `/utils/formatDate.js`.
142
151
 
143
152
  ### Rules
144
153
 
@@ -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,14 +1,13 @@
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
 
7
6
  import { readFileSync, existsSync } from 'node:fs';
8
7
  import { resolve } from 'node:path';
9
- import { checkFileLines, runCmd, countFiles } from './spec-utils.js';
8
+ import { checkFileLines, runCmd, countFiles, checkImports } from './spec-utils.js';
10
9
 
11
- export { checkFileLines, runCmd, countFiles, findFiles, elapsed } from './spec-utils.js';
10
+ export { checkFileLines, runCmd, countFiles, findFiles, elapsed, checkImports } from './spec-utils.js';
12
11
 
13
12
  /** Detect the project's package manager runner (npx, pnpm exec, yarn). */
14
13
  function detectRunner(projectDir) {
@@ -62,42 +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
- runCmd('ESLint', cmds.lint, projectDir, `${jsFiles} files`),
94
- runCmd('Stylelint', cmds.stylelint, projectDir, `${cssFiles} files`),
95
- runCmd('Prettier', cmds.prettier, projectDir, `${jsFiles} files`),
96
- runCmd('Markdown', cmds.mdlint, projectDir, `${mdFiles} files`),
97
- runCmd('JSDoc types', cmds.types, projectDir, `${jsFiles} files`),
98
- runCmd('Security audit', cmds.audit, projectDir),
99
- ];
100
-
131
+ const results = checks.map((c) => c.run());
101
132
  const passed = results.filter(Boolean).length;
102
133
  console.log(`\n${'='.repeat(40)}`);
103
134
  console.log(`${passed}/${results.length} checks passed.`);
@@ -49,6 +49,16 @@ export async function writePackageJson(dest, vars, existing) {
49
49
  typescript: '^6.0.2',
50
50
  };
51
51
 
52
+ const subpathImports = {
53
+ '#store/*': './src/store/*',
54
+ '#utils/*': './src/utils/*',
55
+ '#atoms/*': './src/components/atoms/*',
56
+ '#molecules/*': './src/components/molecules/*',
57
+ '#organisms/*': './src/components/organisms/*',
58
+ '#templates/*': './src/components/templates/*',
59
+ '#pages/*': './src/pages/*',
60
+ };
61
+
52
62
  const pkg = existing
53
63
  ? {
54
64
  ...existing,
@@ -56,6 +66,7 @@ export async function writePackageJson(dest, vars, existing) {
56
66
  description: vars.description,
57
67
  type: 'module',
58
68
  main: 'src/server.js',
69
+ imports: { ...existing.imports, ...subpathImports },
59
70
  scripts: { ...existing.scripts, ...specScripts },
60
71
  dependencies: { ...existing.dependencies, ...specDeps },
61
72
  devDependencies: { ...existing.devDependencies, ...specDevDeps },
@@ -66,6 +77,7 @@ export async function writePackageJson(dest, vars, existing) {
66
77
  type: 'module',
67
78
  description: vars.description,
68
79
  main: 'src/server.js',
80
+ imports: subpathImports,
69
81
  scripts: specScripts,
70
82
  keywords: ['hybrids', 'web-components', 'no-build'],
71
83
  license: 'MIT',
package/lib/spec-utils.js CHANGED
@@ -79,6 +79,38 @@ export function checkFileLines(root, extensions, max, ignoreDirs, label) {
79
79
  return false;
80
80
  }
81
81
 
82
+ /**
83
+ * Check for relative parent imports (`../`) in browser-facing JS files.
84
+ * Same-directory (`./`) and server/test files are allowed.
85
+ * @param {string} root
86
+ * @param {string[]} ignoreDirs
87
+ * @param {string} label
88
+ * @returns {boolean}
89
+ */
90
+ export function checkImports(root, ignoreDirs, label) {
91
+ const start = performance.now();
92
+ const files = findFiles(root, ['.js'], ignoreDirs, root)
93
+ .filter((f) => f.startsWith('src/') && !f.includes('vendor/') && !f.includes('api/') && !f.endsWith('.test.js') && !f.endsWith('server.js'));
94
+ const violations = [];
95
+ const importRe = /(?:^|\n)\s*import\s.*?from\s+['"](\.\.[^'"]*)['"]|(?:^|\n)\s*import\s+['"](\.\.[^'"]*)['"]|(?:^|\n)\s*export\s.*?from\s+['"](\.\.[^'"]*)['"/]/g;
96
+ for (const file of files) {
97
+ const src = readFileSync(resolve(root, file), 'utf-8');
98
+ let m;
99
+ while ((m = importRe.exec(src)) !== null) {
100
+ const spec = m[1] || m[2] || m[3];
101
+ violations.push({ file, spec });
102
+ }
103
+ }
104
+ const time = elapsed(start);
105
+ if (violations.length === 0) {
106
+ console.log(` ✅ ${label} (${files.length} files, ${time})`);
107
+ return true;
108
+ }
109
+ console.log(` ❌ ${label} — ${violations.length} violation(s):`);
110
+ for (const v of violations) console.log(` ${v.file}: import '${v.spec}' → use #prefix/ alias`);
111
+ return false;
112
+ }
113
+
82
114
  /**
83
115
  * Run a shell command, report pass/fail with timing.
84
116
  * @param {string} label
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.7",
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": {
@@ -31,6 +31,15 @@
31
31
  "publishConfig": {
32
32
  "access": "public"
33
33
  },
34
+ "imports": {
35
+ "#store/*": "./src/store/*",
36
+ "#utils/*": "./src/utils/*",
37
+ "#atoms/*": "./src/components/atoms/*",
38
+ "#molecules/*": "./src/components/molecules/*",
39
+ "#organisms/*": "./src/components/organisms/*",
40
+ "#templates/*": "./src/components/templates/*",
41
+ "#pages/*": "./src/pages/*"
42
+ },
34
43
  "keywords": [
35
44
  "web-components",
36
45
  "no-build",
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { html, define, router } from 'hybrids';
7
- import HomeView from '../pages/home/home-view.js';
7
+ import HomeView from '#pages/home/home-view.js';
8
8
 
9
9
  export default define({
10
10
  tag: 'app-router',
@@ -10,7 +10,14 @@
10
10
  "skipLibCheck": true,
11
11
  "types": ["node"],
12
12
  "paths": {
13
- "hybrids": ["../node_modules/hybrids/types/index.d.ts"]
13
+ "hybrids": ["../node_modules/hybrids/types/index.d.ts"],
14
+ "#store/*": ["../src/store/*"],
15
+ "#utils/*": ["../src/utils/*"],
16
+ "#atoms/*": ["../src/components/atoms/*"],
17
+ "#molecules/*": ["../src/components/molecules/*"],
18
+ "#organisms/*": ["../src/components/organisms/*"],
19
+ "#templates/*": ["../src/components/templates/*"],
20
+ "#pages/*": ["../src/pages/*"]
14
21
  }
15
22
  },
16
23
  "include": ["../src/**/*.js", "../scripts/**/*.js"],
@@ -109,6 +109,50 @@ render: ({ items }) => html`
109
109
 
110
110
  ---
111
111
 
112
+ ## Import Map Aliases
113
+
114
+ All cross-directory imports in browser-facing code use `#prefix/` aliases
115
+ defined in the import map (`src/index.html`). This eliminates fragile
116
+ relative paths like `../../../utils/foo.js`.
117
+
118
+ ### Available Prefixes
119
+
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
+
130
+ ### Rules
131
+
132
+ - **No `../` imports.** The spec check (`npm run spec`) flags any `../`
133
+ in browser-facing JS. Use a `#prefix/` alias instead.
134
+ - **Same-directory `./` is fine.** `index.js` re-exports and co-located
135
+ files use `./` naturally.
136
+ - **Server/API files are exempt.** `src/api/` and `src/server.js` run in
137
+ Node, not the browser — they use `./` relative imports.
138
+ - **Test files are exempt.** `*.test.js` files may use relative paths.
139
+ - **jsconfig.json mirrors the import map.** The `paths` entries in
140
+ `.configs/jsconfig.json` let `tsc --checkJs` resolve `#prefix/` imports.
141
+
142
+ ### Examples
143
+
144
+ ```javascript
145
+ // ✅ Good — clear, refactor-safe
146
+ import AppState from '#store/AppState.js';
147
+ import { formatDate } from '#utils/formatDate.js';
148
+ import '#atoms/app-badge/app-badge.js';
149
+
150
+ // ❌ Bad — fragile, hard to read
151
+ import AppState from '../../../store/AppState.js';
152
+ ```
153
+
154
+ ---
155
+
112
156
  ## Error Handling
113
157
 
114
158
  Errors are handled **at the boundary where they are actionable.** Each layer
@@ -193,8 +237,8 @@ state: store(AppState),
193
237
  // In src/utils/filterActive.js
194
238
  export const filterActive = (items) => items.filter(i => i.active);
195
239
 
196
- // In component
197
- import { filterActive } from '../../utils/filterActive.js';
240
+ // In component — use import map alias, never ../
241
+ import { filterActive } from '#utils/filterActive.js';
198
242
  render: ({ items }) => html`<ul>${filterActive(items).map(...)}</ul>`,
199
243
  ```
200
244
 
@@ -121,7 +121,14 @@ declared in `src/index.html`.
121
121
  <script type="importmap">
122
122
  {
123
123
  "imports": {
124
- "hybrids": "/vendor/hybrids/index.js"
124
+ "hybrids": "/vendor/hybrids/index.js",
125
+ "#store/": "/store/",
126
+ "#utils/": "/utils/",
127
+ "#atoms/": "/components/atoms/",
128
+ "#molecules/": "/components/molecules/",
129
+ "#organisms/": "/components/organisms/",
130
+ "#templates/": "/components/templates/",
131
+ "#pages/": "/pages/"
125
132
  }
126
133
  }
127
134
  </script>
@@ -139,6 +146,8 @@ declared in `src/index.html`.
139
146
  2. Import map resolves `'hybrids'` → `/vendor/hybrids/index.js`.
140
147
  3. Server serves the file from `src/vendor/hybrids/index.js`.
141
148
  4. Hybrids' internal imports use relative paths — they resolve naturally.
149
+ 5. `#prefix/` entries resolve cross-directory app imports without `../`.
150
+ For example, `import '#utils/formatDate.js'` resolves to `/utils/formatDate.js`.
142
151
 
143
152
  ### Rules
144
153
 
@@ -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
@@ -5,11 +5,11 @@
5
5
  */
6
6
 
7
7
  import { html, define, store } from 'hybrids';
8
- import AppState from '../../../store/AppState.js';
8
+ import AppState from '#store/AppState.js';
9
9
 
10
10
  /**
11
11
  * @typedef {Object} ThemeToggleHost
12
- * @property {import('../../../store/AppState.js').AppState} state
12
+ * @property {import('#store/AppState.js').AppState} state
13
13
  */
14
14
 
15
15
  /**
@@ -14,7 +14,14 @@
14
14
  <script type="importmap">
15
15
  {
16
16
  "imports": {
17
- "hybrids": "/vendor/hybrids/index.js"
17
+ "hybrids": "/vendor/hybrids/index.js",
18
+ "#store/": "/store/",
19
+ "#utils/": "/utils/",
20
+ "#atoms/": "/components/atoms/",
21
+ "#molecules/": "/components/molecules/",
22
+ "#organisms/": "/components/organisms/",
23
+ "#templates/": "/components/templates/",
24
+ "#pages/": "/pages/"
18
25
  }
19
26
  }
20
27
  </script>
@@ -4,12 +4,12 @@
4
4
  */
5
5
 
6
6
  import { html, define, store } from 'hybrids';
7
- import AppState from '../../store/AppState.js';
8
- import '../../components/atoms/app-button/app-button.js';
7
+ import AppState from '#store/AppState.js';
8
+ import '#atoms/app-button/app-button.js';
9
9
 
10
10
  /**
11
11
  * @typedef {Object} HomeViewHost
12
- * @property {import('../../store/AppState.js').AppState} state
12
+ * @property {import('#store/AppState.js').AppState} state
13
13
  */
14
14
 
15
15
  /** @param {HomeViewHost & HTMLElement} host */
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { html, define, router } from 'hybrids';
7
- import HomeView from '../pages/home/home-view.js';
7
+ import HomeView from '#pages/home/home-view.js';
8
8
 
9
9
  export default define({
10
10
  tag: 'app-router',