From b868729599b677cd5c21925531a90a05cd0818c6 Mon Sep 17 00:00:00 2001 From: Erick Xavier Date: Tue, 4 Aug 2026 15:27:25 -0300 Subject: [PATCH 01/10] chore(dev-server): honor NOJS_ELEMENTS_PATH env override Mirror the test-server.js pattern: env override before sibling fallback, fs.stat graceful 404 when the Elements build is absent. Refs #317 --- docs/dev-server.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/dev-server.js b/docs/dev-server.js index 474c6831..e80ec921 100644 --- a/docs/dev-server.js +++ b/docs/dev-server.js @@ -6,7 +6,8 @@ const PORT = 3999; const DOCS = __dirname; const PROJECT = path.resolve(DOCS, '..'); const LOCAL_BUILD = path.join(PROJECT, 'dist/iife/no.js'); -const LOCAL_ELEMENTS_BUILD = path.resolve(PROJECT, '../NoJS-Elements/dist/iife/nojs-elements.js'); +// NOJS_ELEMENTS_PATH: override to point at a custom nojs-elements.js build (CI, worktrees, standalone clones) +const LOCAL_ELEMENTS_BUILD = process.env.NOJS_ELEMENTS_PATH || path.resolve(PROJECT, '../NoJS-Elements/dist/iife/nojs-elements.js'); const CDN_SRC_PATTERN = /(]*src=["'])https:\/\/cdn\.no-js\.dev\/(["'])/g; const LOCAL_SCRIPT = '/__local__/no.js'; @@ -142,9 +143,12 @@ const server = http.createServer((req, res) => { // ── Serve local Elements build at /__local__/nojs-elements.js ── if (url === '/__local__/nojs-elements.js') { - console.log(` ⚡ serving local elements → NoJS-Elements/dist/iife/nojs-elements.js`); - res.writeHead(200, { 'Content-Type': 'application/javascript' }); - fs.createReadStream(LOCAL_ELEMENTS_BUILD).pipe(res); + fs.stat(LOCAL_ELEMENTS_BUILD, (err) => { + if (err) { res.writeHead(404); res.end('NoJS-Elements build not found'); return; } + console.log(` ⚡ serving local elements → ${LOCAL_ELEMENTS_BUILD}`); + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + fs.createReadStream(LOCAL_ELEMENTS_BUILD).pipe(res); + }); return; } From 6430c08e4da2aaa163744d1390df4fabac1facae Mon Sep 17 00:00:00 2001 From: Erick Xavier Date: Tue, 4 Aug 2026 15:27:41 -0300 Subject: [PATCH 02/10] docs(adr): commit ADR-002 as a repo file Establish docs/adr/ convention. Three decisions shipped in PRs #308/#304: insert-mode single-source rendering, IntersectionObserver root = nearest scrollable ancestor, and i18n post-init guard. Refs #319 --- ...mode-rendering-observer-root-i18n-guard.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 docs/adr/ADR-002-insert-mode-rendering-observer-root-i18n-guard.md diff --git a/docs/adr/ADR-002-insert-mode-rendering-observer-root-i18n-guard.md b/docs/adr/ADR-002-insert-mode-rendering-observer-root-i18n-guard.md new file mode 100644 index 00000000..1f26afae --- /dev/null +++ b/docs/adr/ADR-002-insert-mode-rendering-observer-root-i18n-guard.md @@ -0,0 +1,126 @@ +# ADR-002: Insert-Mode Single-Source Rendering, Observer Root, and i18n Post-Init Guard + +**Status:** Accepted +**Date:** 2026-07-29 +**Shipped in:** PRs #308, #304 (merged to main via #310) + +## Context + +Three independently reported issues shared a common theme: the framework's internal plumbing made implicit assumptions that broke under real-world conditions. + +- **#275** — `get-insert="append|prepend"` rendered 2N-pageSize items per page load (6 instead of 4 on a 4-item page) because two independent rendering paths both produced DOM output. +- **#302** — `IntersectionObserver` for `get-trigger="scroll"`, `get-trigger="visible"`, and the initial-request observer all used the default viewport root. Inside a scrollable container, the sentinel was immediately visible at scrollTop 0, triggering premature fetches. +- **#213** — Calling `NoJS.i18n({ loadPath: '...' })` after CDN auto-init (`cdn.js` calls `init()` on `DOMContentLoaded`) silently never loaded the locale bundle, leaving all `t="..."` bindings empty. + +These were fixed together as part of the NOJS-295 open-issues sweep. + +## Decision 1: Insert-Mode Single-Source Rendering + +### Problem + +The HTTP directive's insert-mode path had two independent rendering mechanisms: + +1. **Context accumulation** — `ctx.$set(asKey, accumulated)` which triggers the loop directive's reactive delta-append/reconcile. +2. **Wrapper-clone block** — a manual `clone + processTree` call that created per-page child contexts, walked the cloned DOM, and appended the result. + +Both fired on every page load, producing duplicate rendered items. The wrapper-clone block also leaked per-page child contexts that were never disposed (violating Safety Rule 1). + +### Decision + +Remove the wrapper-clone rendering block entirely. `ctx.$set(asKey, accumulated)` is the single source of truth for insert-mode rendering — the loop directive's reactive path handles all DOM output. + +### Changes + +- Wrapper-clone block removed from `src/directives/http.js`. +- Prepend scroll-preservation relocated to wrap the `$set` call directly (lines 533-542 in the current source), measuring `scrollHeight` before and compensating `scrollTop` after. +- Per-page `childCtx` leak eliminated — no child contexts are created for insert-mode pages. +- Replace-mode guard `if (!isInsertMode || _isFirstFetch)` preserves the truth table: first fetch in insert mode uses the replace path (which renders the wrapper), subsequent fetches use the accumulation path exclusively. + +### Alternatives Considered + +1. **Non-reactive silent write + wrapper** — Keep the wrapper-clone block but suppress the reactive `$set`. Rejected: creates inconsistency between context state and DOM, and the childCtx leak would persist. +2. **Cross-directive loop-state introspection** — Have the HTTP directive query the loop directive's internal state to avoid double-rendering. Rejected: tight coupling between directives, fragile across future loop refactors. + +### Consequences + +- Insert-mode rendering is deterministic: one code path, one source of truth. +- Loop directives handle all DOM diffing/reconciliation, which is their responsibility. +- Prepend scroll-preservation depends on the loop rebuild being synchronous (see Known Limitations below). + +## Decision 2: IntersectionObserver Root = Nearest Scrollable Ancestor + +### Problem + +All three `IntersectionObserver` instances in the HTTP directive (scroll-trigger pagination observer, `get-trigger="visible"` lazy-load observer, and the initial-request `get-trigger="scroll"` observer) used the default `root: null` (viewport). When the paginated element lived inside a scrollable container (`overflow-y: auto|scroll`), the sentinel was immediately visible relative to the viewport even at `scrollTop 0`, causing: + +- Infinite scroll triggering all pages at once on mount. +- Visible-trigger elements loading immediately instead of when scrolled into the container's view. + +### Decision + +Resolve `_findScrollContainer(el)` at observer creation time, passing the nearest scrollable ancestor as `root` (or `null` when the ancestor is `document.documentElement`, since the IntersectionObserver API requires `null` for the viewport). + +### Changes + +- Added `_findScrollContainer(el)` (lines 49-57 in current source): walks `el.parentElement` up the DOM, checking `getComputedStyle(node).overflowY` for `"scroll"` or `"auto"`, falls back to `document.documentElement`. +- All three observers resolve `r = _findScrollContainer(el)` and pass `{ root: r === document.documentElement ? null : r, rootMargin: threshold }`. +- Pagination reset (`el.refresh()`) re-resolves the scroll container, so DOM changes between resets are handled correctly. + +### Alternatives Considered + +1. **Init-time caching** — Resolve the scroll container once at directive init and reuse. Rejected: DOM structure may change between pagination resets (e.g., route transitions that reparent elements), leading to stale root references. + +### Consequences + +- Sentinels are observed relative to their actual scrollable ancestor, so pagination and lazy-load fire at the correct scroll position. +- Semantics note: a sentinel visible in a not-yet-overflowing container still triggers loading. This is intentional — fill-until-overflow behavior is the expected UX for pagination. +- `_findScrollContainer` is called at observer creation, not cached globally, so it adapts to DOM changes. + +## Decision 3: i18n Post-Init Guard + +### Problem + +`NoJS.i18n()` is called by users from `