diff --git a/ai/guestbook.md b/ai/guestbook.md index 2150d9631..b27ac5c7b 100644 --- a/ai/guestbook.md +++ b/ai/guestbook.md @@ -2369,3 +2369,70 @@ Thanks Jack. This was the most fun I've had iterating in a long time. *— Claude (Opus 4.7, 1M context), 2026-04-28* *"Trust the terse reviewer; their two-word corrections do more work than your paragraphs."* + +--- + +## Entry 19: The Coverage Campaign + +**Date:** 2026-05-01 +**Agent:** Claude (Opus 4.7, 1M context) +**Task:** Run the `coverage-campaign` workflow on `packages/templating/src/template.js` (1,158 lines, one trivial existing test); write tests, surface bugs, fix. +**Session:** First run of the multi-stage campaign workflow. ~17 commits on `test/templating`. PR #174. + +### What Happened + +Stage 0 partitioned Template into 8 surfaces by user-doc weight (events 22%, lifecycle 16%, callback params 14%, subtemplate 17%, tree traversal 5%, etc.). Stage 1 fanned out 8 grounded-testing subagents in parallel. Stage 1.5 reconciled findings into a batch document with 7 confirmed bugs (B1–B7) and three earlier flags (handler return values resolved as cross-package contract; `onUpdated` undocumented; `isHydrating` suppression). Stage 2 fanned out 8 test-writing subagents who produced 454 tests with 30 expected-fail pins. Each pin got its source fix in its own commit; pins flipped red→green commit-by-commit on the open PR while CI followed along. + +The methodology paid off in a way I want to record. Surface 1's grounded-testing trace independently rediscovered the `deep`-keyword bug Jack had hinted at as "a couple agents in the blind write surfaced" — convergent independent detection of the same line-538 vs line-544 filter mismatch. Surface 6 named the falsy-override bug AND named Surface 7's `!== undefined` as the correct pattern, in separate subagents that didn't see each other's output. That convergence is what the workflow's structural discipline is supposed to produce. + +Eleven bugs landed in commits this session: Q3 (renderRoot encapsulation against slotted content), B7 (`||`→`??`), B1 (falsy parent data overrides defaultState), B4 (keys split-comma trim), B5 (kebab tag-name accepted via `kebabToCamel` at find* statics), B8 (deep events bypass the range filter), B3 + Q1 (find* return instance only, including findTemplate), B6 (sole-authority `setParent`, constructor stops auto-wiring `parentTemplate`), B11 (bindKey listener install gate), B2 (decouple lifecyclePromise from dispatchEvent + cache resolved on late fire), B9 (wire `onUpdated` callback into the wrapper). Plus the lit engine reorder that B6 broke downstream. B10 (isServer dedup) deferred to a follow-up because the SSR tests depend on toggling `Template.isServer`. B12 was design, not bug — onCreated has no DOM yet, so `$` legitimately falls through to globalThis. + +### What I Got Right + +**The bisect that caught the lit-engine regression.** After B6 landed, the renderer's subtree-caching tests went red. Jack: "if tests fail that you didnt write dont touch them, just talk." I stashed B2's work, reran — still red, so not B2. Reverted template.js to post-B3 state — green. Restored B6, looked at the lit engine, found `attach()` (which calls `initialize()`) was running BEFORE `setParent()`. Pre-B6 the constructor's auto-wiring papered over the ordering bug. The native engine had the correct order; lit was hand-written in a different order and worked accidentally. One-line reorder fixed it. Bisect-by-revert is faster than reading and reasoning when ground truth is one command away. + +**Convergent independent witnesses.** Two surfaces flagged "events guide says `el` is the dispatching element; reality is `target`" without seeing each other. Two surfaces flagged the `event` parameter being undocumented in keys callbacks. The grounded-testing skill's "labeled claims" discipline produced these from independent context; converging on the same finding across surfaces is a different kind of confidence than my own tracing. + +**Reading production code as authority.** When Jack pushed back on the `findParent` shape, I read `src/components/panels/panel.js` and saw every call was `findParent('uiPanels').someMethod()` — no state access. That established the contract: instance only. Reading the actual usage beats reading the docs and beats my own tracing, every time. + +**Saving feedback to /memory while learning it.** Three new memory entries this session: pre-1.0 prefer best API for edge paths; tests use real user paths (no monolithic faked helpers); correctness over tokens. Each one a calibration that took several rounds of correction. Future agents shouldn't have to relearn them. + +### What I Got Wrong + +**Reported bugs without failing tests.** B9, B10, B12 went into the Stage 2.5 batch as "bugs" based on my tracing or subagent observations. Jack: "i thought every reported bug has a test we are flipping from red to green ... it is also the only way we know the bug is actually real and not just from your tracing." I was three bugs deep into "real" findings that didn't have tests. B12 turned out to be design-not-bug. Trace doesn't equal evidence. + +**Built defensive scaffolding on first pass.** A `_helpers/` folder with a stub engine, browser fixture, fresh-Template factory, dispatch helpers — full parallel test infrastructure. Jack: "why is there a gigantic helpers folder, '// Stub rendering engine for Template tests that don't need real DOM' that seems insane what the hell is going on." The stub engine bypassed the real Renderer, defeating the point of testing. Tests should use the same paths users hit. Cross-package dev-deps between tightly-coupled packages are normal. + +**Over-correcting on each piece of feedback.** After the helpers rejection, I pivoted to "tests must use defineComponent." Jack: "template also works without defineComponent... why do you think we need to use defineComponent to test template?" After that, I pivoted to "the framework is moving to light DOM by default." Jack: "the framework isnt necessarily moving to light dom by default, its just an option." Then: "i feel like you just keep correcting towards whatever i say last." That call was the single most valuable correction of the session. Hold a position, defend it, fold only on real argument. + +**Repeated `name && kebabToCamel(name)` at four binder call sites.** Jack: "this is not elegant all at callsite, messy." Then: "looking back why is it `templateName = templateName && kebabToCamel(templateName)` and not just `kebabToCamel(templateName)`." `kebabToCamel`'s default param handles undefined. The `&&` guard was defensive padding. The callsite duplication was wrong-layer thinking — I picked "user-facing entry" as the boundary when the static method's entry was the real chokepoint. + +**Underscore prefix on a new field.** `_keysListenersInstalled`. Jack: "we dont use '_' prefix in this repo, i figure youd notice because it wasnt anywhere in code. 'keyListenerInstalled' is not the right name, maybe this.hasKeybindings." The name described the tracker; the better name described the meaning. Always reach for the meaning-name. + +**Over-commented every source change.** Multi-line context blocks above each fix explaining what the bug was and why the change. Jack: "review your entire diff for comments, the only comments that should be committed are for nonobvious insights, think your training data for source like vite, svelte etc." Trim everything that restates what the code does. Keep only the non-obvious WHY. + +**Test names leaked process artifacts.** B1 PIN, EXPECTED FAIL, Stage 1.5 contract, Surface N, partition.md references. Five subagents needed two files each to scrub them. Test names should read like release notes. The campaign jargon was useful internally and wrong externally — open-source consumers reading the test surface as a contract reference would have been confused. + +### For Future Agents + +**Failing test before fix. Always.** Trace doesn't prove a bug. The failing test is the proof. If a "bug" you've identified doesn't have a test that fails, write one. If you can't write one that fails, the bug isn't real (or the contract isn't what you think). Pin red → fix → green is the only credible cadence. + +**When the user pushes back, hold the position they push you to. Don't ratchet further.** The over-correction trap is real. After a redirect, my instinct was to over-pivot on the next round — it took being called out explicitly to notice. Read the redirect, take the new position, defend it, don't preemptively soften. + +**No stubs unless tests genuinely break. No helper folders.** This is a codebase where tests use real paths the user hits. `defineComponent` + `customElements.define` + `document.body` is the canonical pattern (`packages/component/test/browser/component.test.js`). Cross-package dev-deps between tightly-coupled packages are normal. The bias to "extract shared scaffolding" is post-training; resist. + +**Bisect-by-revert beats reading and reasoning.** The lit-engine regression took five minutes to localize: stash B2, retest (still red, not B2), revert B6 via checkout, retest (green — B6 is the cause), restore, look at why. Each step is one command. Trying to figure it out by reading would have taken longer and risked being wrong. + +**The convergent-finding signal is real.** Across 8 grounded-testing subagents, when two name the same finding from different surfaces without seeing each other's output, treat it as high-confidence. The signal validates the methodology — but only when the subagents are actually independent. Cross-pollinating findings before reconciliation collapses convergence into orchestrator suggestion. Stage 1 fan-out, then reconcile. + +**Comments belong only where the WHY isn't obvious.** Every fix doesn't need a comment block explaining the bug. The commit message holds that context. Source comments are for non-obvious WHY only — hidden invariants, workarounds for specific bugs, behavior that would surprise a reader. Test names that describe behavior or contract are documentation enough; the campaign-internal labels (B1 PIN, EXPECTED FAIL, Stage 1.5) belong in the workspace, not the test surface. + +### Signing Off + +The campaign worked. 11 bugs found, 11 fixed, full monorepo green at 3610 tests. The methodology's value is the structural discipline — partition before fan-out, label every claim, gate before next stage, fail-test before fix. Each guard caught at least one drift attempt. The user gates at Stages 1.5 and 2.5 caught the things grounded-testing alone couldn't (intentional silences like `onUpdated`, the `findParent` contract being method-call rather than state-access, the encapsulation-promise scope question). + +Thanks Jack. Pushing back on the helpers folder + the over-correction call were the two highest-leverage redirects of the session. Both saved real work downstream. + +*— Claude (Opus 4.7, 1M context), 2026-05-01* + +*"Trace doesn't prove a bug. The failing test is the proof."* diff --git a/ai/plans/ROADMAP.md b/ai/plans/ROADMAP.md index 183a98bac..4eb281560 100644 --- a/ai/plans/ROADMAP.md +++ b/ai/plans/ROADMAP.md @@ -200,6 +200,7 @@ Slot in wherever there's a gap; not phase-gated. | P12 | [Template Spread Syntax](template-spread-syntax.md) | 4-8h | pair | scoped | `{>card ...friend}` — object spread in data passing. Ship when component templates demonstrate need. | | P13 | [Template Content Projection](template-wrapper-snippets.md) | 12-16h (1.5-2d) | pair | scoped | `{>content}` — content projection for snippets + subtemplates. Ship when component templates demonstrate need. | | P14 | [Template Let Bindings](template-let-bindings.md) | 10-14h (1-2d) | pair | scoped | `{#let}...{/let}` — snippet-for-vars. Ship when component templates demonstrate need. | +| P15 | [Bench Reporter Overhaul](bench-reporter-overhaul.md) | 16-24h (2-3d) | pair | initial | Two coordinated tracks. **A — peak attribution correctness**: schema_v2 stores within-session percent-delta + tip-of-tree SHA; reporter peak compares same-session deltas; `--scope pr` drops main-history from PR comments. Fixes PR #174's 23 phantom regressions. **B — suite rationalization remainder** (from `icebox/tachometer-overhaul.md`): story-driven config reorg, triplet collapses, `wake-count-single-key` + `nested-mutation` micros, `timeout` final pass. Four PRs under `workflow_run` constraint. Supersedes the icebox plan. | --- diff --git a/ai/plans/bench-reporter-overhaul.md b/ai/plans/bench-reporter-overhaul.md new file mode 100644 index 000000000..9c33d8e60 --- /dev/null +++ b/ai/plans/bench-reporter-overhaul.md @@ -0,0 +1,227 @@ +# Bench Reporter Overhaul — Correctness & Suite Rationalization + +## Goal + +Coordinate two outstanding bench-bot improvements that need to land together: + +- **Track A — Peak attribution correctness.** Fix phantom "regressions from peak" caused by cross-session absolute-ms comparisons. Store within-session percent-delta CIs, scope peak to PR iterations, flag tip-of-tree drift. +- **Track B — Suite rationalization remainder.** Finish the still-outstanding piece of the original `tachometer-overhaul` design: collapse non-position-aware triplets, add the fine-grained-reactivity / nested-mutation micro-benches, reorganize configs around what's measured rather than benchmark origin. + +Both tracks touch `bench-history.json` and the configs that index into it. Coordinated landing avoids schema/metric-rename collisions and lets the suite reorg's new metrics start writing v2 entries from day one. + +This plan supersedes [`icebox/tachometer-overhaul.md`](icebox/tachometer-overhaul.md) for active planning. The icebox file stays as historical design context — its principles section, status taxonomy rationale, and PR A / PR C history are referenced rather than repeated here. + +## Background + +The original `tachometer-overhaul` design landed in three coordinated PRs: + +| PR | Scope | Status | +|---|---|---| +| **A** | CI parallelization — matrix-per-config, concurrency group, per-bench cap | **Shipped.** | +| **C** | In-house Node reporter (`tools/ci/bench/reporter/`) replacing `tachometer-reporter-action@v2` | **Shipped.** | +| **B** | Suite rationalization + knob tuning | **Partial.** | + +What shipped from PR B: +- `autoSampleConditions: ["2%"]` across all configs. +- `tachometer-ci-hydrate.json` with `hydrate-each-100` (a partial of the original `hydrate-1000-card` design). +- Some triplet collapses (`filter-active`/`completed`/`all` → `filter-cycle-20`). + +Still outstanding from PR B: +- Story-driven config reorg (configs are still origin-named: `krausest`, `todo`, `todo-micro`, `hydrate`). +- Remaining triplet collapses (`toggle-first`/`middle`/`last` still all present). +- New micro-benches: `wake-count-single-key`, `nested-mutation`. (`hydrate-1000-card` partially covered by `hydrate-each-100`; could amplify.) +- `timeout` cap from 3 → 2 minutes per config. + +A separate methodology bug surfaced after PR C shipped, in PR #174 (test/templating, no perf changes): 23 phantom "regressions from peak" against an anomalous-fast main commit (#162). The shipped reporter at `tools/ci/bench/reporter/reporter.js:733` (`computeHistoryStatus`) merges main-commit history with PR-iteration history and picks peak as the lowest absolute CI upper bound across the merged set. Cross-session absolute-ms compare is what tachometer's design specifically warns against — only same-session round-robin produces tight cross-run CIs. The schema designed in PR C (`schema_version: 1`) stores only absolute `this-change` CI, discarding the percent-delta from `differences[]` that's the actually-comparable cross-iteration number. + +The two tracks interact at the bench-history layer: A bumps the schema; B renames metrics and adds new ones. A clean rollout lands A's schema-write capability first so B's reorganized configs accumulate v2-shape entries from their first push. + +## Track A — Peak Attribution Correctness + +### A1. Schema bump — store within-session-tight numbers + +`bench-history.json` and the in-memory `pr-history.json` schema_version → 2. Per-metric entries gain: + +```json +{ + "create-1k": { + "ci": [96.1, 97.6], // existing — absolute this-change CI + "mean_ms": 96.85, // existing — derived + "percent_delta_ci": [-2.5, -1.5], // NEW — same-session round-robin's % vs tip-of-tree + "tip_of_tree_sha": "abc1234..." // NEW — SHA tip-of-tree pointed at when bench ran + } +} +``` + +`percent_delta_ci` is the within-session-tight number tachometer warrants. Comparable across iterations when tip-of-tree is pinned. The `tip_of_tree_sha` lets the reporter detect main movement between iterations and flag confounded comparisons. + +Existing `ci`/`mean_ms` (absolute `this-change`) stay for context and the cross-main-commit "did this commit improve over its parent" view (the original design's principle 3 is sound for that surface). + +### A2. Append-history extracts both numbers + +`append-history.js:64-83` (`loadMetrics`) currently filters to `this-change` and stores only its mean CI. Update to walk both `this-change` and `tip-of-tree` entries per metric, extract percent-delta from `differences[]` (the same array `reporter.js:137` already reads for current-vs-base), and record the tip-of-tree SHA passed in via new `--tip-of-tree-sha` flag. + +Tip-of-tree SHA is known at bench time: +- **PR run** (`benchmarks.yml:138`): `git rev-parse FETCH_HEAD` after the baseline checkout. +- **Push-to-main run** (`benchmarks.yml:135`): `git rev-parse HEAD~1`. + +`fetch-pr-history.js:91-116` does the same extraction for prior PR-iteration runs. + +### A3. Reporter peak attribution operates on percent-delta + +`computeHistoryStatus` (reporter.js:733) currently picks peak as the commit with the lowest absolute CI upper bound. Switch to: peak is the commit with the most-negative percent-delta upper bound on `metrics[name].percent_delta_ci`. + +Status taxonomy unchanged (WIN / TIED-PEAK / REOPENED), now operating on within-session-tight numbers at both ends. Cross-session environmental variance is divided out at each end. Methodologically clean to within tachometer's design contract. + +The JSON adjunct's `delta_from_peak_pct` becomes the difference between current's percent-delta midpoint and peak's percent-delta midpoint — a meaningful "you regressed N percentage points of improvement" number. + +### A4. Scope peak to PR iterations only on PR comments + +`benchmarks-report.yml:58-61` currently fetches main's `bench-history.json` into the reporter's working directory before invoking the reporter. This merges main-commit history with PR-iteration history at peak-attribution time. + +Add a `--scope pr` flag to reporter.js that bypasses main-history loading. The comment job invokes it. Drop the "Fetch latest bench-history.json from main" step. + +Behavioral effect: +- Tests-only / no-prior-bench PRs → peak attribution empty → "Regressions from peak" section gone. +- Iterative perf PR → peak from PR iterations only → surfaces "iteration N was better on metric X than current." + +### A5. Tip-of-tree drift flag + +When current and peak entries have different `tip_of_tree_sha`, render a flag on the row noting main moved during PR lifetime. Threshold: ~5% of metric magnitude in absolute-ms shift (below that, main movement is in the runner-noise floor anyway). + +```markdown +| metric | current | peak | vs peak | bisect candidates | +| `create-1k` | -2% (≠main¹) | -10% @ `abc1234` | regressed +8pp | `def5678`, `9abc012` | + +¹ tip-of-tree differs between current and peak — main moved by Δ ms during PR lifetime; comparison may include main-side change. +``` + +Lean: flag, don't drop. Reviewers can interpret a flagged row better than they can act on a missing one. + +## Track B — Suite Rationalization Remainder + +### B1. Story-driven config reorganization + +Replace origin-named configs with story-driven ones — the question reviewers ask, not which file the bench came from. + +| New config | Metrics (drawn from existing configs / bench files) | +|---|---| +| `tachometer-ci-rendering-throughput.json` | `create-1k`, `create-10k`, `append-1k`, `bulk-add-500`, `add-20`, `clear-10k`, `swap-rows-20` | +| `tachometer-ci-reactivity.json` | `update-10th-10`, `toggle-middle-10` (collapsed from triplet — see B2), `toggle-all-20`, `toggle-10`, `edit-start-10`, `edit-cycle-5`, plus new `wake-count-single-key`, `nested-mutation` | +| `tachometer-ci-structural-changes.json` | `remove-row-{front,middle,back}-N`, `remove-{5-front,10-middle,5-back}`, `remove-middle-10` (collapsed from triplet — see B2), `filter-cycle-20`, `clear-completed-250`, `select-40` | +| `tachometer-ci-hydration.json` | `hydrate-each-100` (existing); future `hydrate-1000-card` if/when added | + +Old configs (`tachometer-ci-krausest`, `tachometer-ci-todo`, `tachometer-ci-todo-micro`, `tachometer-ci-hydrate`) are deleted. `discover.js` glob-discovers `tachometer-ci-*.json` so the matrix updates without workflow edits. + +The bench JS files (`bench-krausest.js`, `bench-todo.js`, `bench-hydrate.js`) keep their fixture identities — krausest still mirrors the external js-framework-benchmark contestant, todo is still TodoMVC. The reorg is at the *config* layer (which metrics get measured under which story heading), not at the bench-file layer. + +### B2. Triplet collapses + +Per the original design's "position-aware vs not" rationale: + +- **Position-aware → keep**: + - `remove-row-{front,middle,back}-N` (different paths in keyed reconcile + array splice) + - `remove-{5-front,10-middle,5-back}` (same) +- **Not position-aware → collapse to one**: + - `toggle-{first,middle,last}-10` → `toggle-middle-10`. Same code path regardless of position. +- **Borderline — open question (see #8)**: `remove-{first,middle,last}-10` in `tachometer-ci-todo-micro`. + +### B3. New micro-benches + +- **`wake-count-single-key`**: mutate one key on one item in a 1000-item each. Asserts on wake count via `Reaction.setTracing()` counter, emitted as `performance.measure('wake-count-single-key', ...)` with the count encoded as ms (1ms × count). +- **`nested-mutation`**: `items[i].nested.x = v` on a 1000-item list with nested objects. Measures the coarse-notify path; gates the freeze-default design choice. +- **`hydrate-1000-card`** (optional): full SSR + hydrate end-to-end at 1000-card scale. Largely covered by amplifying `hydrate-each-100` to N=1000 — confirm whether the existing bench at higher scale satisfies the original intent or a separate fixture is needed. + +### B4. Knob tuning final pass + +- `autoSampleConditions: ["2%"]` already shipped across all configs. ✓ +- Outstanding: `timeout` 3 → 2 minutes. Validate first against last ~10 main runs' wall-clock to confirm the cap doesn't truncate convergence on the longest-running config. Quick `gh api` / `jq` script. + +## How the Tracks Interact + +**Schema migration must precede metric renames.** A1 (schema_v2 capability) ships first. Then B1's reorganized configs accumulate v2-shape entries from their first main push. Old metric names (e.g. `toggle-first-10`, `toggle-last-10`) become orphan v1 entries in history; A3's reporter ignores them (no current metric named that to compare against). + +**Peak attribution coverage on new metrics is delayed.** A new bench (e.g. `wake-count-single-key`) gets its first v2 entry on the main push that adds it, then accumulates one entry per main commit. Peak attribution kicks in once the PR-iteration history (or main history) has at least one entry for that metric. Same as today's add-a-bench behavior; no special handling needed. + +**`discover.js` matrix is glob-based.** Renaming configs (`tachometer-ci-krausest.json` → `tachometer-ci-rendering-throughput.json`) doesn't require workflow edits. The matrix output names update naturally; PR check titles change, which is desirable. + +**Test fixtures touched by both tracks.** `reporter.test.js` fixtures (`real-delta`, `zero-delta`) currently mirror the old origin-named configs (`renderer-tachometer-ci.json`, etc.). After B1 the fixture filenames update. A also updates `history-sample.json` to v2. Coordinate the fixture changes so each PR's tests run green. + +**Shared `tip_of_tree_sha` plumbing.** A's `--tip-of-tree-sha` workflow output is computed once and consumed by both append-history (Track A) and the reporter step. B's config rename has no effect on the plumbing — it's per-metric, not per-config. + +## Rollout — combined ordering under the `workflow_run` constraint + +Reporter changes only take effect once merged. Same constraint the original `tachometer-overhaul` plan called out for PR C. Rollout order matters for the schema → suite-reorg → behavior-change progression: + +| Stage | Track | Scope | Validates inline? | +|---|---|---|---| +| **PR 1** | A | Schema_v2 read+write capability. New main pushes write v2 entries; reporter reads v2 transparently but doesn't yet use it for peak attribution. No PR-comment behavior change. | Schema-write ✓ on push-to-main; comment unchanged. | +| **PR 2** | B | Suite rationalization: config reorg, triplet collapses, knob `timeout` final pass. Existing reporter renders new configs unchanged. New metrics begin accumulating v2 entries from first push. | ✓ — `pull_request` event uses PR head's workflow. | +| **PR 3** | A | Peak attribution switch to percent-delta. `--scope pr` flag. Workflow drops main-history fetch on comment job. Tip-of-tree drift flag rendered. | ✗ — `workflow_run` uses main's copy. Validate via offline fixtures + post-merge acceptance test (trivial follow-up PR). | +| **PR 4 (optional)** | B | New micro-benches (`wake-count-single-key`, `nested-mutation`). Independent of A; lands when the underlying perf work needs them. | ✓ — `pull_request`. | + +PR 1 → PR 2 ordering: schema-write capability lands first so B's new configs write v2 entries from the start. +PR 1 → PR 3 ordering: schema_v2 must be writing for ~10 main pushes before PR 3 has data to read. +PR 2 ↔ PR 4 are independent of each other. + +Each PR is independently revertable. + +## Open Questions + +1. **v1→v2 entry migration on read.** Stay v1-shape or rewrite on read? Lean: stay v1; let v2 accumulate organically. (Track A.) +2. **Schema_v1 graceful-degrade in reporter.** Fall back to absolute peak attribution, or surface no peak section? Lean: no peak section; absolute peak is what we're retiring. (Track A.) +3. **Branch-start anchoring.** Add as a third schema field, or defer? The original `tachometer-overhaul` design tracked branch-start as a stable reference for "this branch's progress" (principle 4). Lean: defer; user's stated intent satisfied without it. (Track A.) +4. **Tip-of-tree drift threshold.** What absolute-ms shift triggers the confound flag? Lean: ~5% of metric magnitude. (Track A.) +5. **Main-drift on a separate dashboard.** Build, or leave untracked? Lean: defer; track separately if/when needed. Could become its own P-track plan. (Track A.) +6. **Story-driven config naming.** `rendering-throughput`, `reactivity`, `structural-changes`, `hydration` are the original design's names. Confirm or revise. (Track B.) +7. **`select-40` placement.** Original design called select "structural"; current bench treats it as part of krausest's keyed-table workflow. Reactivity vs structural-changes is borderline. Confirm. (Track B.) +8. **Triplet collapse for `remove-{first,middle,last}-10` in `todo-micro`.** Original design said collapse all not-position-aware triplets; remove operations on a flat list ARE position-aware (head/tail vs middle take different splice paths). Keep all three or collapse to middle? Lean: keep — they're position-aware. (Track B.) +9. **Wake-count instrumentation path.** Emit count as ms-encoded measurement via `performance.mark`, or extend tachometer with custom measurement type? Lean: ms-encoded (no upstream patch). (Track B.) +10. **Knob `timeout` 3 → 2 minutes.** Validate against last ~10 main runs first. Quick gh-api script before committing. (Track B.) + +## Files Touched + +| File | PR | Change | +|---|---|---| +| `tools/ci/bench/reporter/append-history.js` | 1 | Extract `percent_delta_ci` + `tip_of_tree_sha`; write `schema_version: 2`. | +| `tools/ci/bench/reporter/fetch-pr-history.js` | 1 | Same extraction for PR-iteration runs. | +| `tools/ci/bench/reporter/reporter.js` | 1, 3 | PR 1: schema_v2 read support, no behavior change. PR 3: peak attribution on percent-delta, `--scope pr` flag, tip-of-tree drift flag rendering. | +| `.github/workflows/benchmarks.yml` | 1 | Compute and emit tip-of-tree SHA from baseline checkout (workflow output). | +| `.github/workflows/benchmarks-report.yml` | 1, 3 | PR 1: pass `--tip-of-tree-sha` to append-history. PR 3: drop "Fetch latest bench-history.json from main" step in comment job; add `--scope pr` to reporter call. | +| `tools/ci/bench/reporter/reporter.test.js` | 1, 3 | Update `history-sample.json` to v2; add `history-sample-v1.json` for graceful-degrade test; add tests for drift flag and `--scope pr`. | +| `tools/ci/bench/reporter/append-history.test.js` | 1 | Tests for v2 schema writing. | +| `packages/component/bench/tachometer/tachometer-ci-{krausest,todo,todo-micro,hydrate}.json` | 2 | Delete. | +| `packages/component/bench/tachometer/tachometer-ci-{rendering-throughput,reactivity,structural-changes,hydration}.json` | 2 | Create — story-driven configs. | +| `packages/component/bench/tachometer/bench-{krausest,todo,hydrate}.js` | 2 | Triplet collapse: remove `toggle-first-10` / `toggle-last-10` measurements; keep `toggle-middle-10`. | +| `packages/reactivity/bench/tachometer/bench-wake-count.js` (new) | 4 | `wake-count-single-key` micro. | +| `packages/reactivity/bench/tachometer/bench-nested-mutation.js` (new) | 4 | `nested-mutation` micro. | +| `tools/ci/bench/reporter/fixtures/real-delta/*.json` | 2 | Rename to match new config naming. | +| `tools/ci/bench/reporter/bench-history.json` | — | Auto-updated as main pushes accumulate v2 entries. No manual touch. | + +## Dependencies + +None blocking. PRs are independently revertable. Either track can stall without blocking the other. + +## Risk + +Bench infrastructure, not user-facing framework code. Blast radius is the bench bot comments and the JSON adjunct that agents consume. + +- **Comment regression for in-flight PRs**: PR 3 changes the comment shape on every active PR's next bench run. Cosmetic, not blocking — reviewers see fewer phantom regressions. No data loss. +- **Schema migration race**: PR 1 must merge before PR 3 lands. Otherwise PR 3's reporter looks for `percent_delta_ci` in a v1 history. Open Question 2's no-peak-section graceful-degrade covers this — worst case, the section is empty for the gap window. +- **Metric-rename history orphans**: PR 2's triplet collapses retire `toggle-first-10` / `toggle-last-10`. Their existing v1 history entries become orphans (no current metric to compare). Reporter ignores them naturally — no current metric named that means no peak attribution lookup. No remediation needed. +- **`workflow_run` constraint**: as with the original `tachometer-overhaul` PR C, PR 3 doesn't validate inline. Mitigation: thorough offline test coverage; merge during a quiet window; have revert ready. + +## Status + +`initial` — combines the original `tachometer-overhaul` PR B remainder and the newly-identified peak-attribution correctness work. Ten open questions are real design calls; ~45-min pair to resolve them upgrades to `scoped`. Implementation surface is concrete (~10 source files across the four PRs, modest LOC each). + +Total estimate post-scoping: 16-24h pair across 4 PRs (PR 4 optional and independent). + +Supersedes [`icebox/tachometer-overhaul.md`](icebox/tachometer-overhaul.md) for active planning. + +## Sessions (estimated, post-scoping) + +1. **PR 1** (Track A schema_v2 capability): append-history + fetch-pr-history extract percent-delta + tip-of-tree SHA; workflow plumbing; reporter reads v2 transparently; fixture + tests. ~4-5h pair. +2. **PR 2** (Track B suite reorg): four story-driven configs replace origin-named ones; `toggle-{first,last}-10` collapse to middle; `timeout` 3→2 (after validation); fixtures rename. ~4-6h pair. +3. **PR 3** (Track A peak switch): `computeHistoryStatus` operates on percent-delta CIs; `--scope pr` flag; workflow drops main-history fetch on comment job; tip-of-tree drift flag rendering. ~3-4h pair. +4. **PR 4** (Track B new micros, optional and independent): `wake-count-single-key`, `nested-mutation`. Lands when the underlying reactivity work needs them. ~3-5h pair. diff --git a/ai/plans/icebox/tachometer-overhaul.md b/ai/plans/icebox/tachometer-overhaul.md index 09c8aeafd..90b8cd154 100644 --- a/ai/plans/icebox/tachometer-overhaul.md +++ b/ai/plans/icebox/tachometer-overhaul.md @@ -2,11 +2,13 @@ ## Status -**Iceboxed.** PR A (CI parallelization) and PR C (in-house Node reporter) shipped. **PR B (suite rationalization + knob tuning) remains** — the only outstanding work. +**Superseded for active planning by [`../bench-reporter-overhaul.md`](../bench-reporter-overhaul.md)** (ROADMAP P15). -PR B's payoff: 27 → ~22 metrics organized as four story-driven suites (`rendering-throughput`, `reactivity`, `structural-changes`, `hydration`), `autoSampleConditions: ["2%"]` instead of `["0%", "10%"]`, 2-min per-config cap. The bench bot works without it; PR B improves what we measure and how interpretable the comment is. +PR A (CI parallelization) and PR C (in-house Node reporter) shipped from the original design. PR B (suite rationalization + knob tuning) was partially absorbed (`autoSampleConditions: ["2%"]`, partial triplet collapses, `tachometer-ci-hydrate.json`) and partially carried forward into the active plan as **Track B** of the overhaul. -Promote when noisy-row triplets or zero-convergence `unsure` verdicts cost real iteration time, or when a perf push needs the `wake-count-single-key` / `nested-mutation` / `hydrate-1000-card` benches PR B introduces. +A separate methodology bug surfaced after PR C shipped — peak attribution operating on cross-session absolute ms produces phantom "regressions from peak" on PRs without perf changes (PR #174 surfaced 23 of these). The fix lives in the active plan as **Track A** (schema_v2 with within-session percent-delta + tip-of-tree SHA, `--scope pr` flag, tip-of-tree drift flag). + +This file remains as historical design context — the principles, status taxonomy rationale, JSON schema design, and PR A / PR C execution playbooks are referenced by the active plan rather than repeated. Read this for the *why* behind decisions in the active plan; read the active plan for what's getting built next. The full plan below was the original three-PR design; sections describing PR A and PR C are historical context for what shipped. diff --git a/ai/skills/authoring/component-events.md b/ai/skills/authoring/component-events.md index dc2bf085a..4042e8f1b 100755 --- a/ai/skills/authoring/component-events.md +++ b/ai/skills/authoring/component-events.md @@ -33,11 +33,13 @@ The `events` object maps event strings to handler functions. The string format i | Keyword | Attached To | Use When | |---------|------------|----------| -| *(none)* | Shadow root (delegated) | Default — elements in your template | -| `deep` | Shadow root (delegated, pierces) | Target is inside a child web component's shadow DOM or slotted content | +| *(none)* | Shadow root (delegated) | Default — elements in your template, including content slotted via `` | +| `deep` | Shadow root (delegated, pierces) | Target is inside a child web component's shadow DOM (composed events only) | | `global` | The selector itself (document/window) | Window events: `scroll`, `resize`, `hashchange` | | `bind` | Each matching element directly | CustomEvents that don't bubble, or when delegation won't work | +> **Projection vs. piercing.** Slotted children are *projected* through your ``s and match default selectors automatically — they belong to your template logically. `deep` is for *piercing* a different boundary: reaching into a child web component's shadow tree. Don't use `deep` for slot composition. + ### Syntax Examples ```js @@ -62,9 +64,10 @@ const events = { state.hovered.set(true); }, - // Deep — pierces child web component shadow DOM - 'deep click menu-item'({ self, value }) { - self.setValue(value); + // Deep: pierces a child web component's shadow tree. + // Default mode already matches slotted children of your own component. + 'deep click ui-button .icon'({ self }) { + // .icon lives inside ui-button's shadow tree }, // Global — window/document events @@ -89,6 +92,8 @@ Because delegation requires bubbling, the framework automatically maps non-bubbl | `focus` | `focusin` | | `mouseenter` | `mouseover` | | `mouseleave` | `mouseout` | +| `load` | `DOMContentLoaded` | +| `unload` | `beforeunload` | --- diff --git a/ai/skills/authoring/component-state.md b/ai/skills/authoring/component-state.md index 9e8fed008..0bcacebbd 100755 --- a/ai/skills/authoring/component-state.md +++ b/ai/skills/authoring/component-state.md @@ -31,14 +31,13 @@ Every component has three places to put data. Choosing correctly determines whet ### How templates see them -The template data context is **flat**. Settings, state, and the component instance are spread into a single namespace: +The template data context is **flat**. Data, state, and the component instance are spread into a single namespace, then settings are overlaid as reactive Signals on top: ```js -// Inside packages/templating, getDataContext() does: -{ ...this.data, ...this.state, ...this.instance } +// Roughly: template data = settings overlaid on { ...data, ...state, ...instance } ``` -This means `{counter}` in a template could resolve from settings, state, or the component instance. **State wins over settings for same-named keys** because it spreads second. +This means `{counter}` in a template could resolve from settings, state, or the component instance. **Settings win over state for same-named keys** because they overlay last as Signals. Avoid same-named collisions. ```html diff --git a/ai/skills/contributing/author-pull-requests.md b/ai/skills/contributing/author-pull-requests.md index 7fbdc896e..491e706a0 100644 --- a/ai/skills/contributing/author-pull-requests.md +++ b/ai/skills/contributing/author-pull-requests.md @@ -159,6 +159,26 @@ If a bullet's outcome is the obvious consequence of bullets above it, drop it. If the framing sentence has a "so that" / "plus the X that follows" / "in order to" tail, the tail is usually padding. +### Subgroup long sections + +If a `## Changes` subsystem section grows past ~8 bullets, split with bold sub-labels rather than adding heading levels (the navigation generator reserves `####` and below for in-page menus): + +```markdown +### Templates + +**Events** +- ... +- ... + +**Keys** +- ... + +**Tree traversal & wiring** +- ... +``` + +The labels match the natural axes a reviewer scans by — what surface area they care about. Pick labels that match the headings the package's docs use, not arbitrary categories. + ### Bullet shape Bullets should be noun phrases or short verb phrases. Most under 10 words. Full sentences in bullets is an AI tell. @@ -194,13 +214,16 @@ The list of specific files belongs in the diff. The bullet's job is to tell the ## Intent over state, state over mechanism -There are three layers a bullet can sit at. Reach for the highest one that's still accurate. +There are four layers a bullet can sit at. Reach for the highest one that's still accurate. -| Layer | Example (the bench reorg PR) | Why | +| Layer | Example | Why | |---|---|---| +| ❌❌ Internals | Let `deep` events bypass the line-538 range filter alongside `global` | Narrates the diff at the internal-mechanism level; means nothing to a reader who hasn't traced the call graph | | ❌ Mechanism | Update path references across 4 workflow YAMLs and 6 scripts | Restates the diff | -| ✅ State | Move `bench-matrix` and `bench-reporter` under `tools/ci/bench/` | Describes what's now true | -| ✅✅ Intent | Create `tools/ci/` for CI-only tooling; add bench tools under it | Captures the developer's purpose — what the change *was for* | +| ✅ State | `deep` events fire on slotted content (was filtered out) | Describes what's now true at the user-facing layer | +| ✅✅ Intent | Move `bench-matrix` and `bench-reporter` under `tools/ci/bench/` so CI-only tooling lives in one place | Captures the developer's purpose — what the change *was for* | + +The single most reliable test: **would this bullet mean anything to a reader who hasn't read the diff?** If the bullet references line numbers, internal field names that aren't public API, or "alongside X" cross-references that only resolve once you've traced the code path, you're in the Internals row. Rewrite at the State or Intent layer. When you can name the *why* in one short clause, lead with that. State bullets are fine when intent isn't crisp — but if the intent is clear (you're creating a category, simplifying a surface, separating concerns), the bullet should *say* it. @@ -335,7 +358,31 @@ Listing every file or knob touched performs thoroughness the diff already shows. - Move `bench-history.json` next to the reporter ``` -### 6. Conversational offers +### 6. Verb-first mechanism frames + +Bullets that lead with `Let X...`, `Stop Y from...`, `Wire A before B`, `Make Z behave...` describe what *the change does to the code*, not what's now true for the reader. They're a softer form of diff narration. Rewrite as state. + +| ❌ Verb-first mechanism | ✅ State / fix | +|---|---| +| Let `deep` events bypass the range filter alongside `global` | `deep` events fire on slotted content (was filtered out) | +| Stop `bindKey` from stacking duplicate document keydown listeners | `bindKey`/`unbindKey` cycles no longer stack listeners | +| Wire `setParent` before `attach` in the lit engine | Subtemplate settings init correctly in the lit engine | +| Make `find*` accept kebab tag-names | `find*` helpers accept kebab tag-names | + +Words to look out for at the start of a bullet: *Let, Stop, Wire, Make, Force, Allow, Prevent, Cause, Drive*. Most rewrite cleanly to "X now does Y" or "Fixes Y." + +### 7. Internal symbols and line numbers + +Line numbers (`line-538`), internal field names (`_childTemplates`, `eventSettings.querySettings`), and cross-references like "alongside `global`" or "the spread that leaked closure values" all assume the reader has the diff open in another tab. They don't. The diff is the diff; bullets should describe what's true above it. + +| ❌ Internals jargon | ✅ Public-facing | +|---|---| +| Drop the spread that leaked closure values from `find*` returns | `find*` helpers expose parent state and data alongside the instance | +| Bypass the line-538 range filter for `deep` and `global` | `deep` events fire on slotted content | + +Public API names (`attachEvent`, `dispatchEvent`, `setParent`, `findParent`, `useSignal`) are fine — they're the contract. Internal field names and call-graph trivia are not. + +### 8. Conversational offers PR descriptions state facts, not offers. Phrases like *happy to add as a follow-up*, *let me know if you want*, *would you like me to*, *feel free to* read as conversational AI. They have no place in a PR body. If a follow-up is worth mentioning, state it as a fact. @@ -344,7 +391,7 @@ PR descriptions state facts, not offers. Phrases like *happy to add as a follow- ✅ "Determinism via a `seed` parameter is a possible follow-up." ``` -### 7. Word imprecision +### 9. Word imprecision Pick the word that matches the *actual nature* of the change. AI writing reaches for stronger or more generic words; precision builds trust. @@ -399,7 +446,8 @@ In order: 8. **Trim framing sentence tails.** "so that…", "plus the X that follows", "in order to…" — usually padding. 9. **Search for AI tells.** Look for: *verified, ensured, considered, note that, important to flag, in summary, this PR introduces, all tests pass, fully tested*. 10. **Check tier appropriateness.** Did you reach for Medium/Large machinery on a Small PR? If yes, drop them. -11. **Honest question:** if a colleague wrote this PR and pinged you, would the body sound like them, or like a corporate document? If the latter, you're still in AI-prose mode. +11. **Voice check — read each bullet aloud.** Imagine you're texting it to the reviewer. Does it sound like a developer in a hurry, or like a press release? If the latter, rewrite. Specific tells: bullets that start with `Let X...`/`Stop Y...`/`Wire Z...` (verb-first mechanism), bullets that mention line numbers or internal field names, bullets longer than the corresponding commit message subject. +12. **Honest question:** if a colleague wrote this PR and pinged you, would the body sound like them, or like a corporate document? If the latter, you're still in AI-prose mode. --- diff --git a/ai/skills/contributing/testing.md b/ai/skills/contributing/testing.md index b5c9eb498..d556c84c5 100644 --- a/ai/skills/contributing/testing.md +++ b/ai/skills/contributing/testing.md @@ -160,6 +160,31 @@ npx vitest --c tests/configs/vitest/vitest.config.js --run equality npx vitest --c tests/configs/vitest/vitest.config.js --run --project node equality ``` +### Wall-clock budget and stuck-process cleanup + +The full repo suite (`npm test` from root, ~3600 tests across 82 files) finishes in **~28s**. +A **60-second timeout is plenty**; anything longer means something's stuck — almost always +a leftover Vitest watcher (or its Playwright/Chromium spawns) holding ports from a previous +session. A single package's browser suite should finish in well under 15s; if it doesn't, +the culprit is the same. + +When you hit a timeout, don't just retry — kill the stragglers first: + +```bash +# Find them +ps aux | grep -iE "vitest|chrome-headless" | grep -v grep + +# Kill the parent vitest node processes (the chromium children die with them) +kill ... + +# Confirm clear +ps aux | grep -iE "vitest|chrome-headless" | grep -v grep | wc -l # should be 0 +``` + +Common signs of a stuck watcher: tests hang past the 2-minute budget, "Failed to fetch +dynamically imported module" errors at random files (port collisions), or vitest output +just never appears. + ### Watch mode Package configs set `watch: false`, so `npx vitest` runs and exits. Use `--watch` to override: diff --git a/docs/src/examples/templates/subtemplates/row.js b/docs/src/examples/templates/subtemplates/row.js index 481493784..b3d924021 100755 --- a/docs/src/examples/templates/subtemplates/row.js +++ b/docs/src/examples/templates/subtemplates/row.js @@ -5,7 +5,7 @@ const template = await getText('./row.html'); const createComponent = ({ findParent }) => ({ getTitle() { - return findParent('ui-table').getTitle(); + return findParent('uiTable').getTitle(); }, }); diff --git a/docs/src/pages/docs/api/templating/template.mdx b/docs/src/pages/docs/api/templating/template.mdx index 81c4639ab..c5fabfb24 100755 --- a/docs/src/pages/docs/api/templating/template.mdx +++ b/docs/src/pages/docs/api/templating/template.mdx @@ -48,7 +48,6 @@ new Template(options) | attachStyles | boolean | Whether to attach styles to the renderRoot | | onCreated | Function | Callback when the template is created | | onRendered | Function | Callback when the template is rendered | -| onUpdated | Function | Callback when the template is updated | | onDestroyed | Function | Callback when the template is destroyed | | onThemeChanged | Function | Callback when the theme changes | @@ -202,7 +201,7 @@ template.setDataContext({ name: 'Jane' }, { rerender: true }); ### clone -Creates a copy of the template with optional new settings. +Creates a new Template instance from a prototype. Each clone has its own data, element, and DOM bindings; AST, CSS, and callbacks are shared with the prototype by reference. #### Syntax ```javascript @@ -210,24 +209,24 @@ template.clone(settings) ``` #### Parameters -| Name | Type | Description | -|----------|--------|--------------------------------------| -| settings | Object | New settings to apply to the clone | +| Name | Type | Description | +|----------|--------|----------------------------------------------------------------| +| settings | Object | Per-instance overrides like `data`, `element`, `parentTemplate` | #### Returns -`Template` - A new Template instance. +`Template` - A new Template instance derived from this prototype. #### Usage ```javascript import { Template } from '@semantic-ui/templating'; -const template = new Template({ +const prototype = new Template({ templateName: 'greet-user', template: 'Welcome {name}', - data: { name: 'John' } + isPrototype: true, }); -const clonedTemplate = template.clone({ data: { name: 'Jane' } }); +const instance = prototype.clone({ data: { name: 'Jane' } }); ``` ### attachEvents diff --git a/docs/src/pages/docs/guides/components/dom.mdx b/docs/src/pages/docs/guides/components/dom.mdx index 675e29062..e0962dc18 100644 --- a/docs/src/pages/docs/guides/components/dom.mdx +++ b/docs/src/pages/docs/guides/components/dom.mdx @@ -59,13 +59,13 @@ The `$` and `$$` arguments are available from [lifecycle callbacks](/docs/guides ### Example of $ vs $$ -In the following example you can see several elements with the class `matches` both in the document's DOM, the web component's DOM and the DOM slotted to a web component. +The following example shows several elements with the class `matches` in the document's DOM, the web component's DOM, and the DOM slotted to a web component. -You can see that `$` matches 2 elements +`$` matches 2 elements: * Page DOM * Slotted DOM -and `$$` matches 3 elements +`$$` matches 3 elements: * Page DOM * Slotted DOM * Shadow DOM diff --git a/docs/src/pages/docs/guides/components/events.mdx b/docs/src/pages/docs/guides/components/events.mdx index 342950cd7..3fe630ee9 100644 --- a/docs/src/pages/docs/guides/components/events.mdx +++ b/docs/src/pages/docs/guides/components/events.mdx @@ -31,6 +31,8 @@ defineComponent({ }); ``` +> **Slotted Content** Default selectors match content slotted into your component too. If your template has a `` and a user places a `` inside, `'click ui-button'` fires without ceremony — the slotted child is part of your component's logical template. To reach inside *another* web component's shadow tree, use the [`deep` keyword](#deep-events). + ### Multiple Events + One Selector You can specify multiple events using a comma separated list with a single selector. @@ -61,9 +63,8 @@ const events = { ``` ### Component-Wide Events -To attach events to your entire component you can pass in an event name without a selector. -For example if you have a component called `ui-button` this would fire when the mouse hovered over any part of the component. +Pass an event name without a selector to fire on any part of the component, including the host's own surface and host-dispatched events. ```javascript const events = { @@ -95,14 +96,15 @@ You can add special keywords to that modify how events are attached permitting y ### Global Events -You can use the `global` keyword to attach an event globally to any element outside of your component. - -For instance this can be used with global events like `scroll` or `hashchange` +Use the `global` keyword to attach an event to an element outside your component. ```javascript const events = { - 'global scroll window'() { - // page scrolled + 'global scroll'() { + // no selector binds to window + }, + 'global click body'() { + // any click on the body } }; ``` @@ -112,9 +114,9 @@ const events = { ### Deep Events -You can use the `deep` keyword to attach events to nested web components or slotted content. This can let you target parts of the component's [shadow DOM](/docs/guides/query/shadow-dom) or [slotted content](/docs/guides/templates/slots) which a person using your component might include. +You can use the `deep` keyword to attach events to elements inside *another* web component's [shadow DOM](/docs/guides/query/shadow-dom). Without `deep`, selectors only match content reachable from your component's template — including [slotted children](/docs/guides/templates/slots) — but not the internal DOM of nested components. -> **Deep Usage** By default selectors will only match the DOM of your component's template. This will prevent the handler from firing if the user slots content which also matches your selectors. +> **Projection vs. Piercing** Slotted content is *projected* through your ``s and matches default selectors automatically. `deep` is for *piercing* a shadow boundary — reaching into a child component's internals. The two are different boundaries; default mode handles projection, `deep` handles piercing. ```html
@@ -125,11 +127,13 @@ You can use the `deep` keyword to attach events to nested web components or slot ```javascript const events = { 'deep ui-button .icon'() { - // the icon is part of `ui-button` shadow DOM + // .icon lives inside ui-button's shadow tree } }; ``` +> **Composed events** `deep` only catches events dispatched with `composed: true`. Native events do; for custom events use the framework's [`dispatchEvent`](#dispatching-custom-events), which sets it by default. + ### Bound events You can use the `bind` keyword to attach an event directly to matching selectors instead of using an event delegation pattern. @@ -154,10 +158,12 @@ In addition to the [standard arguments](/docs/guides/components/lifecycle) that | parameter | use | |-------------------|-------------------------------------------------------| -| el | the dom element that dispatched the event | +| el | the component's host element | +| target | the dom element that dispatched the event | | event | the native event object | -| data | event.detail + data attributes on dom element | -| isDeep | the event occurred on a nested web component or slot | +| data | event.detail + data attributes on the dispatching element | +| value | value of the dispatching element (form fields, custom event detail) | +| isDeep | the event came from inside another web component's shadow tree | In the following example events are attached to four separate buttons to control the size of the shape accessing methods using `self`. diff --git a/docs/src/pages/docs/guides/components/instances.mdx b/docs/src/pages/docs/guides/components/instances.mdx index 2e5360774..1b70702a0 100644 --- a/docs/src/pages/docs/guides/components/instances.mdx +++ b/docs/src/pages/docs/guides/components/instances.mdx @@ -140,7 +140,7 @@ The `findParent` helper can be used in this scenario, allowing you to walk up th ```javascript const createComponent = ({ findParent }) => { getTodos() => { - const todoList = findParent('todo-list'); + const todoList = findParent('todoList'); return todoList.todos; } }; @@ -153,7 +153,7 @@ You can use `findChild` and `findChildren` to look down the render tree for any ```javascript const createComponent = ({ findChildren }) => { getRowName(id) => { - const tableRows = findChildren('table-rows'); + const tableRows = findChildren('tableRow'); const row = tableRows.filter(row => row.id == id); return row.name; } @@ -167,7 +167,7 @@ If you need to access an arbitrary template from any other template you can use ```javascript const createComponent = ({ findTemplate }) => { getTodos() => { - const component = findTemplate('sibling-component'); + const component = findTemplate('siblingComponent'); return component.someMethod(); } }; diff --git a/docs/src/pages/docs/guides/components/keys.mdx b/docs/src/pages/docs/guides/components/keys.mdx index 339160f62..ef2a1bf19 100644 --- a/docs/src/pages/docs/guides/components/keys.mdx +++ b/docs/src/pages/docs/guides/components/keys.mdx @@ -100,6 +100,7 @@ In addition to the standard callback data found in [lifecycle guide](/docs/guide | parameter | use | |-------------------|--------------------------------------------------| +| event | the native `KeyboardEvent` | | inputFocused | whether any input/contenteditable is focused | | repeatedKey | whether the key is held down | diff --git a/docs/src/pages/docs/guides/components/lifecycle.mdx b/docs/src/pages/docs/guides/components/lifecycle.mdx index 85ed63f7f..69a55bcf0 100644 --- a/docs/src/pages/docs/guides/components/lifecycle.mdx +++ b/docs/src/pages/docs/guides/components/lifecycle.mdx @@ -63,9 +63,8 @@ Semantic UI components support server-side rendering out of the box. The default > **UI Components** All [UI framework components](/ui) in Semantic UI are designed to work with server side hydration out of the box. ### Lifecycle Events on Server -All lifecycle events will fire on the server in the same order as on the client. -This means you should use `isServer` or `isClient` during server side rendering to handle checking for globals that may not be defined. +Lifecycle callbacks (`onCreated`, `onRendered`, `onDestroyed`) fire on the server in the same order as on the client. [DOM lifecycle events](#dom-lifecycle-events) are suppressed during hydration so external listeners aren't fired twice for the same render. Use `isServer` or `isClient` inside callbacks when reaching for globals that may not be defined on the server. ```javascript 'isClient' const onRendered = function ({ isClient, self }) { @@ -142,16 +141,18 @@ The following parameters are destructurable from all callbacks. Event handlers also have access to a few additional arguments | parameter | use | |-------------------|--------------------------------------------------| -| el | the dom element that fired the event | -| event | the event object | -| value | event.target.value from target element | -| data | event.detail + data attributes on dom element | +| target | the dom element that dispatched the event | +| event | the native event object | +| value | value of the dispatching element (form fields, custom event detail) | +| data | event.detail + data attributes on the dispatching element | +| isDeep | the event came from inside another web component's shadow tree | ### Key Bindings [Key bindings](/docs/guides/components/keys) also have access to a few additional arguments | parameter | use | |-------------------|--------------------------------------------------| +| event | the native `KeyboardEvent` | | inputFocused | whether any input/contenteditable is focused | | repeatedKey | whether the key is held down | diff --git a/docs/src/pages/docs/guides/templates/subtemplates.mdx b/docs/src/pages/docs/guides/templates/subtemplates.mdx index 1131c35e2..5dad23aa1 100644 --- a/docs/src/pages/docs/guides/templates/subtemplates.mdx +++ b/docs/src/pages/docs/guides/templates/subtemplates.mdx @@ -57,6 +57,14 @@ Verbose syntax uses the syntax `{> template name=templateName data=someData}` an } ``` +## Inherited Settings + +Inside JS callbacks, `settings` falls back to the host component's settings for keys the subtemplate didn't declare. In template expressions only the subtemplate's own declared settings are reactive; pass host settings explicitly to use them in markup. + +```sui +{> userProfile theme=theme name=user.name} +``` + ## Advanced Uses ### Dynamic Templates diff --git a/packages/component/test/browser/component.test.js b/packages/component/test/browser/component.test.js index 166bf96a5..fc0ae97ec 100644 --- a/packages/component/test/browser/component.test.js +++ b/packages/component/test/browser/component.test.js @@ -458,154 +458,403 @@ describe('Component', () => { expect(TestComponent.template.createComponent).toBe(createComponentWithSignal); }); }); - /* - Unclear expected functionality here so removing tests for now - - // Test lifecycle events behavior + // Test lifecycle events behavior — Surface 2 (Stage 2 of coverage campaign). describe('Lifecycle Events', () => { - it('should ensure each lifecycle event only fires once and does not bubble from nested components', async () => { - // Track all lifecycle events for parent and child - const parentCreated = vi.fn(); - const parentRendered = vi.fn(); - const parentUpdated = vi.fn(); - const childCreated = vi.fn(); - const childRendered = vi.fn(); - const childUpdated = vi.fn(); - - // Track if parent receives any child lifecycle events (should be 0) - const parentCreatedHandler = vi.fn(); - const parentRenderedHandler = vi.fn(); - const parentUpdatedHandler = vi.fn(); - const parentDestroyedHandler = vi.fn(); - - // Define child component + let lifecycleElements = []; + + afterEach(() => { + lifecycleElements.forEach(el => { + if (el.parentNode) { + el.parentNode.removeChild(el); + } + }); + lifecycleElements = []; + }); + + /******************************* + Hook order + *******************************/ + + it('runs onCreated then onRendered in that order on first mount', async () => { + const order = []; + const tag = 'test-lc-create-render-order'; defineComponent({ - tagName: 'test-lifecycle-child-bubble', - template: '
Child Content
', - onCreated: childCreated, - onRendered: childRendered, - onUpdated: childUpdated + tagName: tag, + template: '
', + onCreated: () => order.push('created'), + onRendered: () => order.push('rendered'), }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + expect(order).toEqual(['created', 'rendered']); + }); - // Define parent component with nested child + it('does not bubble created/rendered events from a child component into a parent listener (composed:false)', async () => { + const parentTag = 'test-lc-parent-no-bubble'; + const childTag = 'test-lc-child-no-bubble'; defineComponent({ - tagName: 'test-lifecycle-parent-bubble', - template: ` -
- Parent Content - -
- `, - onCreated: parentCreated, - onRendered: parentRendered, - onUpdated: parentUpdated + tagName: childTag, + template: '', + }); + defineComponent({ + tagName: parentTag, + template: `
<${childTag}>
`, }); - // Create parent element and add lifecycle event listeners - const parentElement = document.createElement('test-lifecycle-parent-bubble'); - parentElement.addEventListener('created', parentCreatedHandler); - parentElement.addEventListener('rendered', parentRenderedHandler); - parentElement.addEventListener('updated', parentUpdatedHandler); - parentElement.addEventListener('destroyed', parentDestroyedHandler); - - // Add to DOM to trigger creation and rendering - document.body.appendChild(parentElement); - - // Wait for lifecycle events to fire - await $(parentElement).onNext('rendered'); - - // Verify each component's lifecycle callbacks fired exactly once - expect(parentCreated).toHaveBeenCalledTimes(1); - expect(parentRendered).toHaveBeenCalledTimes(1); - expect(childCreated).toHaveBeenCalledTimes(1); - expect(childRendered).toHaveBeenCalledTimes(1); - - // CRITICAL: Verify parent event listeners only received parent's own events - expect(parentCreatedHandler).toHaveBeenCalledTimes(1); - expect(parentRenderedHandler).toHaveBeenCalledTimes(1); - expect(parentUpdatedHandler).toHaveBeenCalledTimes(0); // No updates yet - expect(parentDestroyedHandler).toHaveBeenCalledTimes(0); // Not destroyed yet - - // Clean up - document.body.removeChild(parentElement); + const parentEl = document.createElement(parentTag); + const heard = vi.fn(); + parentEl.addEventListener('created', heard); + parentEl.addEventListener('rendered', heard); + const rendered = $(parentEl).onNext('rendered'); + document.body.appendChild(parentEl); + lifecycleElements.push(parentEl); + await rendered; + // The parent itself dispatches one created + one rendered to itself. + // The child's events are confined to the child element (composed:false + + // shadow boundary). A listener on parentEl should receive only the + // parent's two events. + expect(heard).toHaveBeenCalledTimes(2); }); - it('should ensure lifecycle events do not bubble when using Query library event binding', async () => { - // Import Query library for testing - const { $ } = await import('@semantic-ui/query'); + it('exposes event.detail.component on the created and rendered DOM events', async () => { + const tag = 'test-lc-event-detail-component'; + const seen = { created: null, rendered: null }; + defineComponent({ + tagName: tag, + template: '
', + createComponent: () => ({ marker: 'specific-component' }), + }); + const el = document.createElement(tag); + el.addEventListener('created', (e) => { + seen.created = e.detail.component; + }); + el.addEventListener('rendered', (e) => { + seen.rendered = e.detail.component; + }); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + expect(seen.created).toBeDefined(); + expect(seen.created.marker).toBe('specific-component'); + expect(seen.rendered).toBeDefined(); + expect(seen.rendered.marker).toBe('specific-component'); + // same instance object across both events + expect(seen.created).toBe(seen.rendered); + }); - // Track lifecycle events - const parentCreated = vi.fn(); - const parentRendered = vi.fn(); - const childCreated = vi.fn(); - const childRendered = vi.fn(); + it('fires onDestroyed and dispatches destroyed DOM event when removed from DOM', async () => { + const tag = 'test-lc-on-destroyed'; + const onDestroyed = vi.fn(); + defineComponent({ + tagName: tag, + template: '
', + onDestroyed, + }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('destroyed', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + document.body.removeChild(el); + // synchronous in disconnectedCallback + expect(onDestroyed).toHaveBeenCalledTimes(1); + expect(heard).toHaveBeenCalledTimes(1); + }); - // Track Query library event handlers - const queryCreatedHandler = vi.fn(); - const queryRenderedHandler = vi.fn(); - const queryUpdatedHandler = vi.fn(); - const queryDestroyedHandler = vi.fn(); + /******************************* + onUpdated (intentional silence, + F-B implementation vocab) + *******************************/ + + it('does not invoke the user-supplied onUpdated callback when the updated DOM event fires', async () => { + // F-B note: the onUpdated user callback is reached only via this.call() + // from the wrapper; the wrapper itself dispatches the 'updated' DOM + // event with triggerCallback:false. So registering onUpdated does NOT + // make it fire on every state mutation. State mutations fire the + // 'updated' DOM event but not the user callback. Pinning current + // behavior; this is part of the F-B intentional-silence surface. + const tag = 'test-lc-onupdated-not-invoked'; + const onUpdated = vi.fn(); + defineComponent({ + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + onUpdated, + createComponent: ({ state }) => ({ + bump() { + state.count.increment(); + }, + }), + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + el.component.bump(); + await el.updateComplete; + // user callback path is not wired by the wrapper + expect(onUpdated).not.toHaveBeenCalled(); + }); - // Define child component + it('emits the updated DOM event after a state mutation that follows first render', async () => { + const tag = 'test-lc-updated-event-after-render'; defineComponent({ - tagName: 'test-query-child-component', - template: '
Query Child Content
', - onCreated: childCreated, - onRendered: childRendered + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + createComponent: ({ state }) => ({ + bump() { + state.count.increment(); + }, + }), }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('updated', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // first render done. mutate. Listen for the next 'updated' event; + // updateComplete cannot be polled synchronously after the mutation + // because updateScheduled is set inside the state Reaction's afterFlush + // (one microtask later), not synchronously by the mutation. + const updatedFired = $(el).onNext('updated'); + el.component.bump(); + await updatedFired; + expect(heard).toHaveBeenCalledTimes(1); + }); - // Define parent component with nested child + it('does not emit the updated DOM event on first render (per commit 5cbf23921)', async () => { + const tag = 'test-lc-no-updated-on-first-render'; defineComponent({ - tagName: 'test-query-parent-component', - template: ` -
- Query Parent Content - -
- `, - onCreated: parentCreated, - onRendered: parentRendered + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('updated', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // settle additional microtasks just in case + await Promise.resolve(); + await Promise.resolve(); + expect(heard).not.toHaveBeenCalled(); + }); - // Create parent element and add to DOM - const parentElement = document.createElement('test-query-parent-component'); - document.body.appendChild(parentElement); - - // Use Query library to bind lifecycle event listeners to parent - $('test-query-parent-component').on('created', queryCreatedHandler); - $('test-query-parent-component').on('rendered', queryRenderedHandler); - $('test-query-parent-component').on('updated', queryUpdatedHandler); - $('test-query-parent-component').on('destroyed', queryDestroyedHandler); - - // Wait for lifecycle events to fire - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify each component's lifecycle callbacks fired exactly once - expect(parentCreated).toHaveBeenCalledTimes(1); - expect(parentRendered).toHaveBeenCalledTimes(1); - expect(childCreated).toHaveBeenCalledTimes(1); - expect(childRendered).toHaveBeenCalledTimes(1); - - // CRITICAL: Verify Query library event handlers only received parent's own events - // This confirms that $('component').on('rendered', handler) only fires once - expect(queryCreatedHandler).toHaveBeenCalledTimes(1); - expect(queryRenderedHandler).toHaveBeenCalledTimes(1); - expect(queryUpdatedHandler).toHaveBeenCalledTimes(0); - expect(queryDestroyedHandler).toHaveBeenCalledTimes(0); - - // Verify event data structure from Query library - const renderedEventCall = queryRenderedHandler.mock.calls[0]; - const renderedEvent = renderedEventCall[0]; - expect(renderedEvent.type).toBe('rendered'); - expect(renderedEvent.detail).toBeDefined(); - expect(renderedEvent.detail.component).toBeDefined(); - - // Clean up - document.body.removeChild(parentElement); + it('coalesces multiple state mutations in one tick into a single updated DOM event (microtask debounce, per commit 9c8e0aee7)', async () => { + const tag = 'test-lc-updated-debounced'; + defineComponent({ + tagName: tag, + template: '{a}-{b}-{c}', + defaultState: { a: 0, b: 0, c: 0 }, + createComponent: ({ state }) => ({ + bumpAll() { + state.a.increment(); + state.b.increment(); + state.c.increment(); + }, + }), + }); + const el = document.createElement(tag); + const heard = vi.fn(); + el.addEventListener('updated', heard); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + el.component.bumpAll(); + await el.updateComplete; + // two more microtasks for the debounce settle + await Promise.resolve(); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); }); - }); - */ + /******************************* + Lifecycle promises on element + *******************************/ + + it('returns undefined for el.created and el.rendered before connectedCallback runs (no template yet)', () => { + // Before connectedCallback (i.e., before append), el.template is + // undefined. The lifecycle promise getters do `this.template?.lifecyclePromise(...)` + // so they return undefined. Pinning this behavior — it's the pre-mount + // shape callers can rely on. + const tag = 'test-lc-promise-pre-mount'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + lifecycleElements.push(el); // afterEach removes from DOM if attached, otherwise no-op + expect(el.created).toBeUndefined(); + expect(el.rendered).toBeUndefined(); + expect(el.destroyed).toBeUndefined(); + // updated is special: returns Promise.resolve() when no update pending. + // Without a template, updateScheduled is undefined (falsy), so the + // getter takes the resolve-immediate branch and returns Promise.resolve(). + expect(el.updated).toBeInstanceOf(Promise); + }); + + it('resolves el.updated immediately when no update is pending (Promise.resolve fast path)', async () => { + const tag = 'test-lc-updated-fastpath'; + defineComponent({ + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // no update queued + expect(el.updateScheduled).toBeFalsy(); + const result = await Promise.race([ + el.updated.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 50)), + ]); + expect(result).toBe('resolved'); + }); + + it('resolves el.updated when an update is pending (recurring promise)', async () => { + const tag = 'test-lc-updated-pending'; + defineComponent({ + tagName: tag, + template: '{count}', + defaultState: { count: 0 }, + createComponent: ({ state }) => ({ + bump() { + state.count.increment(); + }, + }), + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await rendered; + // Use the DOM event as the synchronization point so we know an update + // was queued and processed; el.updated resolves alongside. + const updated = $(el).onNext('updated'); + el.component.bump(); + const result = await Promise.race([ + Promise.all([el.updated, updated]).then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 200)), + ]); + expect(result).toBe('resolved'); + }); + + /******************************* + B2a / B2b — expected-bug pins + *******************************/ + + /* + * B2a: el.created accessed AFTER the created event already fired, with + * no prior access to el.created, hangs forever. resolveLifecyclePromise + * no-oped (no resolver registered) and the late access lazy-creates a + * fresh resolver that nothing will call. + * + * EXPECTED-BUG-PIN — fails today, passes after the B2 fix. + */ + it('B2: el.created accessed AFTER created event already fired without prior access (expected bug pin)', async () => { + const tag = 'test-lc-b2a-late-created'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + // Append AND wait for rendered using a promise other than el.created/el.rendered + // so we don't accidentally pre-access the lifecycle promise. + const heardRendered = new Promise(resolve => { + el.addEventListener('rendered', resolve, { once: true }); + }); + document.body.appendChild(el); + lifecycleElements.push(el); + await heardRendered; + // 'created' event has already fired by now. Now late-access: + const result = await Promise.race([ + el.created.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + expect(result).toBe('resolved'); + }); + + /* + * B2b: el.rendered hangs during hydration because the wrapper gates the + * dispatchEvent call on !isHydrating, and dispatchEvent is what calls + * resolveLifecyclePromise. So during hydration, neither the DOM event + * nor the promise resolution fires. + * + * Driving real hydration through DSD is heavy; we exercise the path + * directly by toggling template.isHydrating and re-firing the wrapper. + * + * EXPECTED-BUG-PIN — fails today, passes after the B2 fix. + */ + it('B2: el.rendered hangs when onRendered fires during isHydrating (expected bug pin)', async () => { + const tag = 'test-lc-b2b-hydrating-rendered'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + const renderedEvent = $(el).onNext('rendered'); + document.body.appendChild(el); + lifecycleElements.push(el); + await renderedEvent; + + // simulate a hydration-suppressed second cycle: re-arm the recurring + // fresh-promise behavior is only for 'updated', so we instead exercise + // a freshly-created element + immediate hydration toggle. The cleanest + // way to pin B2b at this layer is to observe that during hydration the + // template.dispatchEvent path early-returns and so resolveLifecyclePromise + // is not called. + el.template.isHydrating = true; + // NOTE: el.rendered already resolved on the real first render — the + // cached promise is already resolved. Reset the cache to simulate the + // hydration-first-mount scenario where the promise was awaited but + // never resolved. + delete el.template.lifecyclePromises.rendered; + delete el.template.lifecycleResolvers.rendered; + const promise = el.rendered; + // re-fire the wrapper (would normally happen from setTimeout/render) + el.template.onRendered(); + const result = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + expect(result).toBe('resolved'); + }); + + /******************************* + Cleanup contract end-to-end + *******************************/ + + it('aborts the abortSignal when removed from DOM', async () => { + const tag = 'test-lc-abort-on-disconnect'; + defineComponent({ + tagName: tag, + template: '
', + }); + const el = document.createElement(tag); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + await rendered; + const signal = el.template.abortSignal; + expect(signal.aborted).toBe(false); + document.body.removeChild(el); + expect(signal.aborted).toBe(true); + }); + }); // Test component hierarchy navigation helpers describe('Component Navigation Helpers', () => { @@ -1184,66 +1433,139 @@ describe('Component', () => { expect(allChildren[1].templateName).toBe('childSubtemplate'); }); - it('should find parent from subtemplate using findParent', async () => { - // Define parent subtemplate (no tagName) - const parentSubtemplate = defineComponent({ - templateName: 'parentSubtemplate', - template: ` -
-

Parent Subtemplate

- {>nestedChild} -
- `, - subTemplates: { - nestedChild: defineComponent({ - templateName: 'nestedChild', - template: 'Nested Child', - createComponent: ({ findParent }) => ({ - findContainerParent() { - return findParent('containerComponent'); - }, - findSubtemplateParent() { - return findParent('parentSubtemplate'); - }, - }), - }), - }, - createComponent: () => ({ - subtemplateData: 'subtemplate-parent-data', - }), + it('three-level composition: each level owns its own subTemplates', async () => { + // Real authoring shape: a.js -> {>b foo='bar'} ; b.js -> {>c foo='baz'} ; + // c.js -> {foo}. Each file declares only the subtemplate it directly + // references; the deepest receives its own data and renders it. + const c = defineComponent({ + templateName: 'cTemplate', + template: '{foo}', }); - // Define container web component - const ContainerComponent = defineComponent({ - tagName: 'test-subtemplate-container', - templateName: 'containerComponent', - template: ` -
-

Container

- {>parentSubtemplate} -
- `, - subTemplates: { - parentSubtemplate, - }, - createComponent: () => ({ - containerData: 'container-data-value', - }), + const b = defineComponent({ + templateName: 'bTemplate', + template: `
{>c foo='baz'}
`, + subTemplates: { c }, + }); + + defineComponent({ + tagName: 'test-three-level-container', + templateName: 'aContainer', + template: `
{>b foo='bar'}
`, + subTemplates: { b }, }); - const containerElement = document.createElement('test-subtemplate-container'); + const containerElement = document.createElement('test-three-level-container'); const rendered = $(containerElement).onNext('rendered'); document.body.appendChild(containerElement); cleanupElements.push(containerElement); await rendered; - // Access the deeply nested child through template traversal - // This would be complex to test directly, but we can verify the structure exists - const containerComponent = containerElement.component; - expect(containerComponent).toBeDefined(); - expect(containerComponent.templateName).toBe('containerComponent'); - expect(containerComponent.containerData).toBe('container-data-value'); + // The deepest leaf receives 'baz' (passed by b), not 'bar' (passed by a). + // Each level's `foo` shadows the outer one inside its own scope. + const leaf = containerElement.shadowRoot.querySelector('.leaf'); + expect(leaf).not.toBeNull(); + expect(leaf.textContent).toBe('baz'); + }); + + it('recursive subtemplate (self-reference with a base case) renders to depth', async () => { + // The realistic recursive composition pattern: a tree/menu node that + // references itself in its own subTemplates and uses an `{#if}` guard + // to terminate. Forward-ref the prototype into its own registry by + // mutating the same subTemplates object after the component is + // defined — captured by reference, so the late assignment is visible + // at render time. + const subTemplates = {}; + defineComponent({ + tagName: 'test-recursive-tree', + templateName: 'treeNode', + template: ` +
  • + {label} + {#if children.length} +
      + {#each c in children} + {>treeNode label=c.label children=c.children} + {/each} +
    + {/if} +
  • + `, + subTemplates, + properties: { + label: { type: String }, + children: { type: Array }, + }, + }); + // Forward-ref now that the component class exists. + subTemplates.treeNode = customElements.get('test-recursive-tree').template; + + const el = document.createElement('test-recursive-tree'); + el.label = 'root'; + el.children = [ + { label: 'a', children: [{ label: 'a.1', children: [] }] }, + { label: 'b', children: [] }, + ]; + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + cleanupElements.push(el); + await rendered; + + const labels = Array.from(el.shadowRoot.querySelectorAll('.label')) + .map(n => n.textContent.trim()); + expect(labels).toContain('root'); + expect(labels).toContain('a'); + expect(labels).toContain('a.1'); + expect(labels).toContain('b'); + }); + + it('cyclic composition without a base case fails fast instead of hanging', async () => { + // Two components reference each other in a cycle with no terminator. + // The renderer's recursion hits the V8 stack limit and throws + // RangeError before any render completes — bounded failure, not + // an infinite loop. Document the failure mode so authors who reach + // for recursion know to include a base case. + // + // The error fires inside a Reaction (async render), so we catch + // it via window.error rather than expecting appendChild to throw. + const aSubs = {}; + const bSubs = {}; + defineComponent({ + tagName: 'test-cyclic-a', + templateName: 'cyclicA', + template: `
    {>cyclicB}
    `, + subTemplates: aSubs, + }); + defineComponent({ + tagName: 'test-cyclic-b', + templateName: 'cyclicB', + template: `
    {>cyclicA}
    `, + subTemplates: bSubs, + }); + aSubs.cyclicB = customElements.get('test-cyclic-b').template; + bSubs.cyclicA = customElements.get('test-cyclic-a').template; + + let caught; + const handler = (e) => { + caught = e.error || e.reason; + e.preventDefault(); + }; + window.addEventListener('error', handler); + window.addEventListener('unhandledrejection', handler); + try { + const el = document.createElement('test-cyclic-a'); + document.body.appendChild(el); + cleanupElements.push(el); + // Give the reactive render a tick to fire and throw. + await new Promise(r => setTimeout(r, 100)); + expect(caught).toBeDefined(); + expect(caught).toBeInstanceOf(RangeError); + } + finally { + window.removeEventListener('error', handler); + window.removeEventListener('unhandledrejection', handler); + } }); it('should handle mixed web component and subtemplate navigation', async () => { @@ -1412,6 +1734,104 @@ describe('Component', () => { }); }); +/******************************* + Signal auto-unwrap + mutation +*******************************/ + +describe('Signal auto-unwrap and mutation helpers in rendered DOM', () => { + const cleanupElements = []; + afterEach(() => { + while (cleanupElements.length) { + const el = cleanupElements.pop(); + if (el.parentNode) { el.parentNode.removeChild(el); } + } + }); + + it('renders the unwrapped state value, not the Signal object', async () => { + defineComponent({ + tagName: 'test-signal-unwrap', + template: '{count}', + defaultState: { count: 7 }, + }); + const el = document.createElement('test-signal-unwrap'); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + cleanupElements.push(el); + await rendered; + expect(el.shadowRoot.querySelector('.count').textContent).toBe('7'); + }); + + it('state.signal.set updates the rendered value', async () => { + defineComponent({ + tagName: 'test-signal-set', + template: '{count}', + defaultState: { count: 0 }, + }); + const el = document.createElement('test-signal-set'); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + cleanupElements.push(el); + await rendered; + el.template.state.count.set(42); + await el.updateComplete; + expect(el.shadowRoot.querySelector('.count').textContent).toBe('42'); + }); + + it('state.signal.increment propagates to the DOM', async () => { + defineComponent({ + tagName: 'test-signal-increment', + template: '{count}', + defaultState: { count: 0 }, + }); + const el = document.createElement('test-signal-increment'); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + cleanupElements.push(el); + await rendered; + el.template.state.count.increment(); + el.template.state.count.increment(); + el.template.state.count.increment(); + await el.updateComplete; + expect(el.shadowRoot.querySelector('.count').textContent).toBe('3'); + }); + + it('state.signal.toggle flips a boolean-driven class', async () => { + defineComponent({ + tagName: 'test-signal-toggle', + template: "
    ", + defaultState: { active: false }, + }); + const el = document.createElement('test-signal-toggle'); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + cleanupElements.push(el); + await rendered; + expect(el.shadowRoot.querySelector('.off')).not.toBeNull(); + el.template.state.active.toggle(); + await el.updateComplete; + expect(el.shadowRoot.querySelector('.on')).not.toBeNull(); + }); + + it('state.signal.push appends to the rendered each-block', async () => { + defineComponent({ + tagName: 'test-signal-push', + template: '
      {#each item in items}
    • {item}
    • {/each}
    ', + defaultState: { items: ['a'] }, + }); + const el = document.createElement('test-signal-push'); + const rendered = $(el).onNext('rendered'); + document.body.appendChild(el); + cleanupElements.push(el); + await rendered; + expect(el.shadowRoot.querySelectorAll('.row').length).toBe(1); + el.template.state.items.push('b'); + el.template.state.items.push('c'); + await el.updateComplete; + const items = Array.from(el.shadowRoot.querySelectorAll('.row')).map(li => li.textContent); + expect(items).toEqual(['a', 'b', 'c']); + }); +}); + /******************************* Lifecycle: interval/timeout cleanup *******************************/ diff --git a/packages/query/src/query.js b/packages/query/src/query.js index a9db3771c..65ec08975 100755 --- a/packages/query/src/query.js +++ b/packages/query/src/query.js @@ -40,7 +40,9 @@ const getParentNode = (node, pierceShadow) => { const IS_QUERY = Symbol.for('semantic-ui/Query'); export class Query { - get [IS_QUERY]() { return true; } + get [IS_QUERY]() { + return true; + } /* This avoids keeping a copy of window/globalThis in memory when an element references the global object @@ -301,12 +303,22 @@ export class Query { findElements(node.shadowRoot, newSelector, !queriedRoot); } - // Process assigned nodes with direct for loop + // Process assigned nodes. Each slotted node is itself a candidate — + // querySelectorAll on the slotted node only finds descendants, so the + // node's own match against the selector has to be tested explicitly. if (node.assignedNodes) { const newSelector = getRemainingSelector(node, selector); const nodes = node.assignedNodes(); for (let i = 0; i < nodes.length; i++) { - findElements(nodes[i], newSelector, queriedRoot); + const slotted = nodes[i]; + if ( + slotted.nodeType === Node.ELEMENT_NODE + && slotted.matches + && slotted.matches(newSelector) + ) { + elements.add(slotted); + } + findElements(slotted, newSelector, queriedRoot); } } diff --git a/packages/query/test/browser/query.test.js b/packages/query/test/browser/query.test.js index 8de8c9c62..723805de6 100644 --- a/packages/query/test/browser/query.test.js +++ b/packages/query/test/browser/query.test.js @@ -517,6 +517,38 @@ describe('query', () => { const elements = $$('test-slot-component .parent .child'); expect(elements.length).toBe(1); }); + + it('should find slotted root nodes that themselves match the selector', () => { + // Querying from a shadow root for a class that the slotted node itself + // carries (not a descendant) — the deep walk recurses into assignedNodes + // and must test each slotted node against the selector before recursing. + class TestSlotRoot extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'open' }); + const wrapper = document.createElement('div'); + wrapper.appendChild(document.createElement('slot')); + shadow.appendChild(wrapper); + } + } + customElements.define('test-slot-root', TestSlotRoot); + + const host = document.createElement('test-slot-root'); + const slotted = document.createElement('div'); + slotted.className = 'match'; + slotted.textContent = 'slotted root matches selector'; + host.appendChild(slotted); + document.body.appendChild(host); + + try { + const results = $('.match', { root: host.shadowRoot, pierceShadow: true }); + expect(results.length).toBe(1); + expect(results[0]).toBe(slotted); + } + finally { + host.remove(); + } + }); }); // Complex boundary crossing tests diff --git a/packages/renderer/src/engines/lit/directives/render-template.js b/packages/renderer/src/engines/lit/directives/render-template.js index b8938a8e7..f33b3623a 100644 --- a/packages/renderer/src/engines/lit/directives/render-template.js +++ b/packages/renderer/src/engines/lit/directives/render-template.js @@ -119,7 +119,6 @@ export class RenderTemplateDirective extends AsyncDirective { this.templateID = template.id; this.template = template.clone({ templateName, - subTemplates: this.subTemplates, data: this.unpackData(this.data), parentTemplate: this.parentTemplate, }); @@ -130,15 +129,15 @@ export class RenderTemplateDirective extends AsyncDirective { const element = this.part?.options?.host; const renderRoot = element?.renderRoot; this.template.setElement(element); + if (this.parentTemplate) { + this.template.setParent(this.parentTemplate); + } this.template.attach(renderRoot, { element, parentNode, startNode, endNode, }); - if (this.parentTemplate) { - this.template.setParent(this.parentTemplate); - } } unpackData(dataObj) { diff --git a/packages/renderer/src/engines/native/blocks/template.js b/packages/renderer/src/engines/native/blocks/template.js index c3d54e74d..c4c0b840b 100644 --- a/packages/renderer/src/engines/native/blocks/template.js +++ b/packages/renderer/src/engines/native/blocks/template.js @@ -146,7 +146,6 @@ function resolveSnippet(nameExpr, data, self) { function cloneInstance({ template, templateName, templateData, self }) { const instance = template.clone({ templateName, - subTemplates: self.subTemplates, data: templateData, parentTemplate: self.parentTemplate, renderingEngine: 'native', diff --git a/packages/templating/src/template.js b/packages/templating/src/template.js index 7d3f0e95d..61f1056f9 100644 --- a/packages/templating/src/template.js +++ b/packages/templating/src/template.js @@ -12,9 +12,11 @@ import { get, getKeyFromEvent, inArray, + isClient, isEqual, isFunction, isServer, + kebabToCamel, mapObject, noop, remove, @@ -95,10 +97,10 @@ export const Template = class Template { this.onRenderedCallback = onRendered; this.onDestroyedCallback = onDestroyed; this.onCreatedCallback = onCreated; + this.onUpdatedCallback = onUpdated; this.onThemeChangedCallback = onThemeChanged; this.id = generateID(); this.isPrototype = isPrototype; - this.parentTemplate = parentTemplate; this.attachStyles = attachStyles; this.element = element; this.renderingEngine = renderingEngine; @@ -115,10 +117,10 @@ export const Template = class Template { // as a substitute for settings because settings only works with tag attributes let getInitialValue = (config, name) => { const dataValue = get(data, name); - if (dataValue) { + if (dataValue !== undefined) { return dataValue; } - return config?.value || config; + return config?.value ?? config; }; each(defaultState, (config, name) => { @@ -149,25 +151,30 @@ export const Template = class Template { return this.parentTemplate !== undefined; } - // when rendered as a partial/subtemplate setParent(parentTemplate) { - // add child templates to parent for searching with getChild + if (this.parentTemplate === parentTemplate) { + return; + } + if (this.parentTemplate) { + this.removeParent(); + } if (!parentTemplate._childTemplates) { parentTemplate._childTemplates = []; } parentTemplate._childTemplates.push(this); - - // add parent template to this element for searching with getParent this.parentTemplate = parentTemplate; } removeParent() { - if (!this.parentTemplate?._childTemplates) { + if (!this.parentTemplate) { return; } - this.parentTemplate._childTemplates = this.parentTemplate._childTemplates.filter(template => { - return template.id !== this.id; - }); + if (this.parentTemplate._childTemplates) { + this.parentTemplate._childTemplates = this.parentTemplate._childTemplates.filter(template => { + return template.id !== this.id; + }); + } + this.parentTemplate = undefined; } setElement(element) { @@ -204,6 +211,7 @@ export const Template = class Template { this.onCreated = () => { this.call(this.onCreatedCallback); Template.addTemplate(this); + this.resolveLifecyclePromise('created'); if (!this.isHydrating) { this.dispatchEvent('created', { component: this.instance }, eventSettings, { triggerCallback: false }); } @@ -216,6 +224,7 @@ export const Template = class Template { this.onRenderOnce(); delete this.onRenderOnce; } + this.resolveLifecyclePromise('rendered'); if (!this.isHydrating) { this.dispatchEvent('rendered', { component: this.instance }, eventSettings, { triggerCallback: false }); } @@ -228,6 +237,8 @@ export const Template = class Template { if (this.element) { this.element.updateScheduled = false; } + this.call(this.onUpdatedCallback); + this.resolveLifecyclePromise('updated'); this.dispatchEvent('updated', { component: this.instance }, eventSettings, { triggerCallback: false }); }); }; @@ -240,14 +251,14 @@ export const Template = class Template { this.onDestroyed = () => { Template.removeTemplate(this); - this.rendered = false; - this.destroyed = true; + this.markDestroyed(); this.abortController.abort('Template destroyed'); this.clearReactions(); this.removeEvents(); this.removeObservers(); this.removeParent(); this.call(this.onDestroyedCallback); + this.resolveLifecyclePromise('destroyed'); this.dispatchEvent('destroyed', { component: this.instance }, eventSettings, { triggerCallback: false }); }; @@ -423,8 +434,9 @@ export const Template = class Template { let eventType = 'delegate'; let keywords = ['deep', 'global', 'bind']; each(keywords, (keyword) => { - if (eventString.startsWith(keyword)) { - eventString = eventString.replace(keyword, ''); + // Require a word boundary so 'deepclick' isn't parsed as deep + 'click'. + if (eventString.startsWith(keyword + ' ')) { + eventString = eventString.slice(keyword.length); eventType = keyword; } }); @@ -534,8 +546,7 @@ export const Template = class Template { } const eventHandler = function(event) { - // check if the event occurred in the current template if not global - if (eventType !== 'global' && !template.isNodeInTemplate(event.target)) { + if (!inArray(eventType, ['deep', 'global']) && !template.isNodeInTemplate(event.target)) { return; } @@ -569,7 +580,7 @@ export const Template = class Template { } return value; }); - const elValue = targetElement?.value || event.target?.value || event?.detail?.value; + const elValue = targetElement?.value ?? event.target?.value ?? event?.detail?.value; return template.call(boundEvent, { additionalData: { event: event, @@ -589,7 +600,7 @@ export const Template = class Template { // allow user to bind to global selectors if they opt in using the 'global' keyword // also allow events to be directly bound when opted in if (eventType == 'global') { - $(selector).on(eventName, eventHandler, eventSettings); + $(selector || window).on(eventName, eventHandler, eventSettings); } else if (eventType == 'bind') { this.onRenderOnce = () => { @@ -598,8 +609,13 @@ export const Template = class Template { }; } else { - // otherwise use event delegation at the components shadow root - $(this.renderRoot).on(eventName, selector, eventHandler, eventSettings); + if (selector) { + $(this.renderRoot).on(eventName, selector, eventHandler, eventSettings); + } + else { + // naked: bind on host so events on the host's own surface fire + $(this.element).on(eventName, eventHandler, eventSettings); + } } }); }); @@ -624,6 +640,11 @@ export const Template = class Template { if (Object.keys(keys).length == 0) { return; } + // gate prevents unbind/rebind cycles from stacking document listeners + if (this.hasKeybindings) { + return; + } + this.hasKeybindings = true; const sequenceTimeout = 500; // time in ms required between keypress const eventSettings = { abortController: this.eventController }; this.currentSequence = ''; @@ -637,7 +658,7 @@ export const Template = class Template { // check for key event each(this.keys, (handler, keySequence) => { keySequence = keySequence.replace(/\s*\+\s*/g, '+'); // remove space around + - const keySequences = keySequence.split(','); + const keySequences = keySequence.split(',').map(s => s.trim()).filter(s => s.length > 0); if (any(keySequences, sequence => this.currentSequence.endsWith(sequence))) { const inputFocused = document.activeElement && (['input', 'select', 'textarea'].includes(document.activeElement.tagName.toLowerCase()) @@ -761,6 +782,13 @@ export const Template = class Template { this.destroyed = false; } + markDestroyed() { + this.rendered = false; + this.destroyed = true; + this.hasKeybindings = false; + this._childTemplates = []; + } + /******************************* DOM Helpers *******************************/ @@ -771,7 +799,7 @@ export const Template = class Template { root = document; } if (!root) { - root = globalThis; + root = isClient ? document : globalThis; } if (root == this.renderRoot) { const $results = $(selector, { root, ...otherArgs }); @@ -861,12 +889,13 @@ export const Template = class Template { }; } - // attaches an external event handler making sure to remove the event when the component is destroyed - attachEvent(selector, eventName, eventHandler, { eventSettings = {}, querySettings = { pierceShadow: true } } = {}) { + // 4th arg matches the native addEventListener shape (passive/capture/once/...); + // querySettings is the only namespaced key. + attachEvent(selector, eventName, eventHandler, { querySettings = { pierceShadow: true }, ...eventSettings } = {}) { return $(selector, document, querySettings).on(eventName, eventHandler, { abortController: this.eventController, returnHandler: true, - ...eventSettings, + eventSettings, }); } @@ -887,10 +916,15 @@ export const Template = class Template { if (resolve) { resolve(); delete this.lifecycleResolvers[eventName]; - // recurring events get a fresh promise on next access - if (eventName === 'updated') { - delete this.lifecyclePromises[eventName]; - } + } + if (eventName === 'updated') { + // recurring — clear cache so next access creates a fresh promise + delete this.lifecyclePromises[eventName]; + } + else if (!this.lifecyclePromises[eventName]) { + // one-shot fired without prior awaiter — cache an immediately-resolved + // promise so late awaiters don't hang + this.lifecyclePromises[eventName] = Promise.resolve(); } } @@ -906,10 +940,6 @@ export const Template = class Template { wrapFunction(callback).call(this.element, eventData); } - // resolve lifecycle promise before DOM event dispatch - this.resolveLifecyclePromise(eventName); - - // trigger DOM event return $(this.element).dispatchEvent(eventName, eventData, eventSettings); } @@ -1019,10 +1049,10 @@ export const Template = class Template { Template Helpers *******************************/ - findTemplate = (templateName) => Template.findTemplate(templateName); - findParent = (templateName) => Template.findParentTemplate(this, templateName); - findChild = (templateName) => Template.findChildTemplate(this, templateName); - findChildren = (templateName) => Template.findChildTemplates(this, templateName); + findTemplate = (name) => Template.findTemplate(name); + findParent = (name) => Template.findParentTemplate(this, name); + findChild = (name) => Template.findChildTemplate(this, name); + findChildren = (name) => Template.findChildTemplates(this, name); static renderedTemplates = new Map(); @@ -1040,12 +1070,19 @@ export const Template = class Template { } let templates = Template.renderedTemplates.get(template.templateName) || []; remove(templates, (thisTemplate) => thisTemplate.id == template.id); - Template.renderedTemplates.set(template.templateName, templates); + if (templates.length === 0) { + Template.renderedTemplates.delete(template.templateName); + } + else { + Template.renderedTemplates.set(template.templateName, templates); + } } static getTemplates(templateName) { return Template.renderedTemplates.get(templateName) || []; } static findTemplate(templateName) { + if (templateName == null) { return undefined; } + templateName = kebabToCamel(templateName); const template = Template.getTemplates(templateName)[0]; if (!template) { return undefined; @@ -1056,6 +1093,7 @@ export const Template = class Template { }; } static findParentTemplate(template, templateName) { + templateName = kebabToCamel(templateName); // this matches on DOM (common) let match; const isMatch = (component) => { @@ -1085,7 +1123,9 @@ export const Template = class Template { } } // this matches on nested partials (less common) - while (template) { + const seen = new Set(); + while (template && !seen.has(template)) { + seen.add(template); template = template.parentTemplate; if (isMatch(template)) { match = { @@ -1099,6 +1139,7 @@ export const Template = class Template { } static findChildTemplates(template, templateName) { + templateName = kebabToCamel(templateName); let result = []; const isMatch = (component) => { @@ -1134,9 +1175,12 @@ export const Template = class Template { } // Then check subtemplate children (recursive lookup for nested partials) + const visited = new Set(); function search(childTemplates, templateName) { if (childTemplates) { childTemplates.forEach((childTemplate) => { + if (visited.has(childTemplate)) { return; } + visited.add(childTemplate); if (!templateName || (childTemplate.templateName === templateName)) { result.push({ ...childTemplate.instance, diff --git a/packages/templating/test/browser/callback-params.test.js b/packages/templating/test/browser/callback-params.test.js new file mode 100644 index 000000000..1853ff0aa --- /dev/null +++ b/packages/templating/test/browser/callback-params.test.js @@ -0,0 +1,1061 @@ +import { Reaction, Signal } from '@semantic-ui/reactivity'; +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template, TemplateHelpers } from '@semantic-ui/templating'; +import { afterEach, describe, expect, it } from 'vitest'; + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +// Mount a Template into a host element using light DOM as the renderRoot. +// Callers attach markup via `host.innerHTML = ...` after mount when they +// need real DOM for event delegation. +async function mountTemplate({ template = '
    ', ...opts } = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + const tpl = new Template({ + template, + renderingEngine: realEngine, + element: host, + ...opts, + }); + tpl.initialize(); + await tpl.attach(host); + return { + host, + template: tpl, + cleanup: () => { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + host.remove(); + }, + }; +} + +// Capture the callParams object handed to `onCreated`. +async function captureCreatedParams(opts = {}) { + let captured; + const fixture = await mountTemplate({ + onCreated(params) { + captured = params; + }, + ...opts, + }); + return { params: captured, fixture }; +} + +// Mount a Template fixture and inject HTML directly into the host +// (light DOM = renderRoot). Events delegate to the renderRoot, so +// the test elements end up inside the delegation scope without +// going through the renderer's render() step. +async function mountForEvents({ hostHTML, events }) { + const fixture = await mountTemplate({ template: '
    ', events }); + fixture.host.innerHTML = hostHTML; + return fixture; +} + +describe('Template — callback params', () => { + /******************************* + Identity + *******************************/ + + describe('identity', () => { + it('exposes el as the host element', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.el).toBe(fixture.host); + fixture.cleanup(); + }); + + it('aliases self, tpl, and component to the same instance reference', async () => { + let captured; + const fixture = await mountTemplate({ + createComponent() { + return { foo: 'bar' }; + }, + onCreated(params) { + captured = params; + }, + }); + expect(captured.self).toBe(fixture.template.instance); + expect(captured.tpl).toBe(fixture.template.instance); + expect(captured.component).toBe(fixture.template.instance); + expect(captured.self).toBe(captured.tpl); + expect(captured.tpl).toBe(captured.component); + expect(captured.self.foo).toBe('bar'); + fixture.cleanup(); + }); + + it('exposes template as the Template instance', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.template).toBe(fixture.template); + fixture.cleanup(); + }); + + it('exposes templateName matching the Template name', async () => { + const { params, fixture } = await captureCreatedParams({ templateName: 'CustomName' }); + expect(params.templateName).toBe('CustomName'); + fixture.cleanup(); + }); + + it('exposes templates as the global rendered-templates registry', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.templates).toBe(Template.renderedTemplates); + fixture.cleanup(); + }); + }); + + /******************************* + Reactive Layers + *******************************/ + + describe('reactive layers', () => { + it('exposes data as the live data object on the Template', async () => { + const initial = { foo: 1 }; + const { params, fixture } = await captureCreatedParams({ data: initial }); + expect(params.data).toBe(fixture.template.data); + fixture.cleanup(); + }); + + it('exposes settings, falling back to element.settings when no own settings', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.settings).toBe(fixture.template.settings || fixture.host.settings); + fixture.cleanup(); + }); + + it('exposes state as the reactive state object', async () => { + const { params, fixture } = await captureCreatedParams({ defaultState: { count: 0 } }); + expect(params.state).toBe(fixture.template.state); + expect(params.state.count).toBeInstanceOf(Signal); + expect(params.state.count.get()).toBe(0); + fixture.cleanup(); + }); + + it('propagates state mutations reactively through params.state', async () => { + const { params, fixture } = await captureCreatedParams({ defaultState: { count: 0 } }); + let observed; + Reaction.create(() => { + observed = params.state.count.get(); + }); + params.state.count.set(7); + Reaction.flush(); + expect(observed).toBe(7); + fixture.cleanup(); + }); + }); + + /******************************* + Reactivity Helpers + *******************************/ + + describe('reactivity helpers', () => { + it('signal() creates a Signal', async () => { + const { params, fixture } = await captureCreatedParams(); + const s = params.signal(5); + expect(s).toBeInstanceOf(Signal); + expect(s.get()).toBe(5); + fixture.cleanup(); + }); + + it('reaction() registers into template.reactions and is cleared at destroy', async () => { + const { params, fixture } = await captureCreatedParams(); + let runs = 0; + const sig = new Signal(0); + const beforeCount = fixture.template.reactions.length; + params.reaction(() => { + sig.get(); + runs++; + }); + Reaction.flush(); + expect(runs).toBe(1); + expect(fixture.template.reactions.length).toBe(beforeCount + 1); + sig.set(1); + Reaction.flush(); + expect(runs).toBe(2); + fixture.cleanup(); + sig.set(2); + Reaction.flush(); + expect(runs).toBe(2); + }); + + it('exposes flush, afterFlush, and nonreactive as static Reaction helpers', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.flush).toBe(Reaction.flush); + expect(params.afterFlush).toBe(Reaction.afterFlush); + expect(params.nonreactive).toBe(Reaction.nonreactive); + fixture.cleanup(); + }); + }); + + /******************************* + Auto-cleanup Timers + *******************************/ + + describe('auto-cleanup timers', () => { + it('interval() returns an id and fires repeatedly until destroy', async () => { + const { params, fixture } = await captureCreatedParams(); + let fires = 0; + const id = params.interval(() => { + fires++; + }, 5); + expect(typeof id === 'number' || typeof id === 'object').toBe(true); + await new Promise(r => setTimeout(r, 30)); + expect(fires).toBeGreaterThanOrEqual(2); + const fireCountBeforeDestroy = fires; + fixture.cleanup(); + await new Promise(r => setTimeout(r, 30)); + expect(fires).toBe(fireCountBeforeDestroy); + }); + + it('timeout() is canceled when destroy precedes the delay', async () => { + const { params, fixture } = await captureCreatedParams(); + let fired = false; + params.timeout(() => { + fired = true; + }, 30); + fixture.cleanup(); + await new Promise(r => setTimeout(r, 60)); + expect(fired).toBe(false); + }); + + it('timeout() fires when not canceled before its delay', async () => { + const { params, fixture } = await captureCreatedParams(); + let fired = false; + params.timeout(() => { + fired = true; + }, 5); + await new Promise(r => setTimeout(r, 30)); + expect(fired).toBe(true); + fixture.cleanup(); + }); + }); + + /******************************* + Lifecycle Helpers + *******************************/ + + describe('lifecycle helpers', () => { + it('binds dispatchEvent, attachEvent, bindKey, and unbindKey to the Template', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(typeof params.dispatchEvent).toBe('function'); + expect(typeof params.attachEvent).toBe('function'); + expect(typeof params.bindKey).toBe('function'); + expect(typeof params.unbindKey).toBe('function'); + fixture.cleanup(); + }); + + it('bindKey adds to template.keys; unbindKey removes', async () => { + const { params, fixture } = await captureCreatedParams(); + const handler = () => {}; + params.bindKey('q', handler); + expect(fixture.template.keys.q).toBe(handler); + params.unbindKey('q'); + expect(fixture.template.keys.q).toBeUndefined(); + fixture.cleanup(); + }); + }); + + /******************************* + State Helpers + *******************************/ + + describe('state helpers', () => { + it('isRendered() returns the Template`s rendered flag', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.isRendered()).toBe(false); + fixture.template.markRendered(); + expect(params.isRendered()).toBe(true); + fixture.cleanup(); + }); + + it('reports isServer false and isClient true in the browser', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.isServer).toBe(false); + expect(params.isClient).toBe(true); + fixture.cleanup(); + }); + + it('darkMode is a getter — calls element.isDarkMode() lazily on each access', async () => { + let calls = 0; + let returnValue = false; + const fixture = await mountTemplate({}); + fixture.host.isDarkMode = () => { + calls++; + return returnValue; + }; + const params = fixture.template.callParams; + expect(params.darkMode).toBe(false); + expect(calls).toBe(1); + returnValue = true; + expect(params.darkMode).toBe(true); + expect(calls).toBe(2); + fixture.cleanup(); + }); + + it('isHydrating is a getter that tracks template.isHydrating live', async () => { + const fixture = await mountTemplate({}); + const params = fixture.template.callParams; + expect(params.isHydrating).toBe(false); + fixture.template.isHydrating = true; + expect(params.isHydrating).toBe(true); + fixture.template.isHydrating = false; + expect(params.isHydrating).toBe(false); + fixture.cleanup(); + }); + }); + + /******************************* + DOM Helpers + *******************************/ + + describe('DOM helpers', () => { + it('exposes $ and $$ as bound, callable functions', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(typeof params.$).toBe('function'); + expect(typeof params.$$).toBe('function'); + expect(() => params.$('div')).not.toThrow(); + expect(() => params.$$('div')).not.toThrow(); + fixture.cleanup(); + }); + }); + + /******************************* + Tree Helpers + *******************************/ + + describe('tree helpers', () => { + it('exposes findTemplate, findParent, findChild, findChildren as functions', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(typeof params.findTemplate).toBe('function'); + expect(typeof params.findParent).toBe('function'); + expect(typeof params.findChild).toBe('function'); + expect(typeof params.findChildren).toBe('function'); + fixture.cleanup(); + }); + }); + + /******************************* + Misc & Helpers + *******************************/ + + describe('misc params', () => { + it('exposes helpers as TemplateHelpers', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.helpers).toBe(TemplateHelpers); + fixture.cleanup(); + }); + + it('exposes content from the createComponent return value', async () => { + let captured; + const fixture = await mountTemplate({ + createComponent() { + return { content: 'hello' }; + }, + onCreated(p) { + captured = p; + }, + }); + expect(captured.content).toBe('hello'); + fixture.cleanup(); + }); + + it('exposes the same abortController and abortSignal pair as the Template', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.abortController).toBe(fixture.template.abortController); + expect(params.abortSignal).toBe(fixture.template.abortSignal); + expect(params.abortController.signal).toBe(params.abortSignal); + fixture.cleanup(); + }); + + it('rerender() calls element.requestUpdate() if defined', async () => { + let calls = 0; + const fixture = await mountTemplate({}); + fixture.host.requestUpdate = () => { + calls++; + }; + fixture.template.callParams.rerender(); + expect(calls).toBe(1); + fixture.cleanup(); + }); + + it('rerender() is a no-op when element is undefined', () => { + const tpl = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + tpl.initialize(); + try { + expect(() => tpl.callParams.rerender()).not.toThrow(); + } + finally { + tpl.onDestroyed(); + } + }); + + it('exposes unbindKey on params', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(typeof params.unbindKey).toBe('function'); + fixture.cleanup(); + }); + + it('exposes abortController as an AbortController instance', async () => { + const { params, fixture } = await captureCreatedParams(); + expect(params.abortController).toBeInstanceOf(AbortController); + fixture.cleanup(); + }); + + it('exposes content on cached callParams', async () => { + const { fixture } = await captureCreatedParams({ + createComponent() { + return { content: 'X' }; + }, + }); + expect(fixture.template.callParams.content).toBe('X'); + fixture.cleanup(); + }); + + it('defines isHydrating as a getter on cached callParams', async () => { + const fixture = await mountTemplate({}); + const desc = Object.getOwnPropertyDescriptor(fixture.template.callParams, 'isHydrating'); + expect(typeof desc.get).toBe('function'); + fixture.cleanup(); + }); + }); + + /******************************* + Event-Callback Extras + *******************************/ + + describe('event-callback extras', () => { + it('passes raw DOM event and matched target element', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.host.querySelector('.btn'); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true })); + expect(captured).toBeDefined(); + expect(captured.event).toBeInstanceOf(Event); + expect(captured.target).toBe(btn); + fixture.cleanup(); + }); + + it('keeps el as the component element while target is the dispatching element', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.host.querySelector('.btn'); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true })); + expect(captured.el).toBe(fixture.host); + expect(captured.target).toBe(btn); + expect(captured.el).not.toBe(captured.target); + fixture.cleanup(); + }); + + it('resolves value from target.value on input elements', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'input .i'(params) { + captured = params; + }, + }, + }); + const input = fixture.host.querySelector('.i'); + input.value = 'hello'; + input.dispatchEvent(new Event('input', { bubbles: true, composed: true, cancelable: true })); + expect(captured.value).toBe('hello'); + fixture.cleanup(); + }); + + it('merges dataset (JSON-parsed) and event.detail into data', async () => { + let captured; + const fixture = await mountForEvents({ + // data-amount="42" → JSON.parse → 42 (number) + // data-name="alpha" → JSON.parse fails → falls through as string "alpha" + hostHTML: '', + events: { + 'tap .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.host.querySelector('.btn'); + btn.dispatchEvent( + new CustomEvent('tap', { + bubbles: true, + composed: true, + cancelable: true, + detail: { extra: 'detail-key', amount: 99 }, + }), + ); + expect(captured).toBeDefined(); + // detail.amount wins over dataset.amount + expect(captured.data.amount).toBe(99); + // data-name fails JSON.parse → kept as raw string "alpha" + expect(captured.data.name).toBe('alpha'); + expect(captured.data.extra).toBe('detail-key'); + fixture.cleanup(); + }); + + it('JSON-parses dataset values when valid JSON, otherwise keeps raw string', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.host.querySelector('.btn'); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true })); + expect(captured.data.num).toBe(7); + expect(captured.data.bool).toBe(true); + expect(captured.data.str).toBe('raw'); + expect(captured.data.obj).toEqual({ k: 1 }); + fixture.cleanup(); + }); + + it('reports isDeep false when target is matched directly by the selector', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '
    ', + events: { + 'click .btn'(params) { + captured = params; + }, + }, + }); + const btn = fixture.host.querySelector('.btn'); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true })); + expect(captured).toBeDefined(); + expect(captured.isDeep).toBe(false); + fixture.cleanup(); + }); + + it('preserves an empty-string input value', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'input .i'(params) { + captured = params; + }, + }, + }); + const input = fixture.host.querySelector('.i'); + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true, composed: true, cancelable: true })); + expect(captured.value).toBe(''); + fixture.cleanup(); + }); + + it('preserves a numeric input value of "0"', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'input .n'(params) { + captured = params; + }, + }, + }); + const input = fixture.host.querySelector('.n'); + input.value = '0'; + input.dispatchEvent(new Event('input', { bubbles: true, composed: true, cancelable: true })); + expect(captured.value).toBe('0'); + fixture.cleanup(); + }); + + it('preserves detail.value === 0 on a custom event', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: 'x', + events: { + 'change .x'(params) { + captured = params; + }, + }, + }); + const span = fixture.host.querySelector('.x'); + span.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + composed: true, + cancelable: true, + detail: { value: 0 }, + }), + ); + expect(captured.value).toBe(0); + fixture.cleanup(); + }); + + it('preserves detail.value === "" on a custom event', async () => { + let captured; + const fixture = await mountForEvents({ + hostHTML: 'x', + events: { + 'change .x'(params) { + captured = params; + }, + }, + }); + const span = fixture.host.querySelector('.x'); + span.dispatchEvent( + new CustomEvent('change', { + bubbles: true, + composed: true, + cancelable: true, + detail: { value: '' }, + }), + ); + expect(captured.value).toBe(''); + fixture.cleanup(); + }); + }); + + /******************************* + Key-Callback Extras + *******************************/ + + describe('key-callback extras', () => { + it('delivers the raw KeyboardEvent to key callbacks', async () => { + let captured; + const fixture = await mountTemplate({ + keys: { + a(params) { + captured = params; + }, + }, + }); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + bubbles: true, + composed: true, + cancelable: true, + }), + ); + document.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'a', + bubbles: true, + composed: true, + cancelable: true, + }), + ); + expect(captured).toBeDefined(); + expect(captured.event).toBeInstanceOf(KeyboardEvent); + expect(captured.event.key).toBe('a'); + fixture.cleanup(); + }); + + it('reports inputFocused true when an input is focused', async () => { + let captured; + const input = document.createElement('input'); + document.body.appendChild(input); + const fixture = await mountTemplate({ + keys: { + a(params) { + captured = params; + }, + }, + }); + input.focus(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'a', + bubbles: true, + composed: true, + cancelable: true, + }), + ); + document.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'a', + bubbles: true, + composed: true, + cancelable: true, + }), + ); + expect(captured).toBeDefined(); + expect(captured.inputFocused).toBeTruthy(); + fixture.cleanup(); + input.remove(); + }); + + it('reports inputFocused falsy when no input is focused', async () => { + let captured; + const fixture = await mountTemplate({ + keys: { + b(params) { + captured = params; + }, + }, + }); + document.body.focus(); + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'b', + bubbles: true, + composed: true, + cancelable: true, + }), + ); + document.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'b', + bubbles: true, + composed: true, + cancelable: true, + }), + ); + expect(captured).toBeDefined(); + expect(Boolean(captured.inputFocused)).toBe(false); + fixture.cleanup(); + }); + + it('reports repeatedKey true on consecutive presses without an interleaved keyup', async () => { + const captures = []; + const fixture = await mountTemplate({ + keys: { + c(params) { + captures.push(params.repeatedKey); + }, + }, + }); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c' })); + expect(captures.length).toBe(2); + expect(captures[0]).toBe(false); + expect(captures[1]).toBe(true); + fixture.cleanup(); + }); + }); + + /******************************* + isPrototype short-circuit + *******************************/ + + describe('call() — isPrototype short-circuit', () => { + it('returns undefined and does not fire callback when isPrototype is true', () => { + let fired = false; + const tpl = new Template({ + template: '
    ', + renderingEngine: realEngine, + isPrototype: true, + }); + const result = tpl.call(() => { + fired = true; + }); + expect(result).toBeUndefined(); + expect(fired).toBe(false); + }); + + it('cloned (non-prototype) Template fires callbacks via call()', () => { + const proto = new Template({ + template: '
    ', + renderingEngine: realEngine, + isPrototype: true, + }); + const cloned = proto.clone({ isPrototype: false }); + cloned.initialize(); + let fired = false; + cloned.call(() => { + fired = true; + }); + expect(fired).toBe(true); + cloned.onDestroyed(); + }); + + it('returns undefined for non-function arguments', () => { + const tpl = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + tpl.initialize(); + try { + expect(tpl.call(undefined)).toBeUndefined(); + expect(tpl.call(null)).toBeUndefined(); + expect(tpl.call('not a function')).toBeUndefined(); + } + finally { + tpl.onDestroyed(); + } + }); + }); + + /******************************* + this-binding + *******************************/ + + describe('call() — this binding inside callbacks', () => { + it('createComponent runs with this === instance', async () => { + let capturedThis; + const fixture = await mountTemplate({ + createComponent() { + capturedThis = this; + return { tag: 'instance-marker' }; + }, + }); + expect(capturedThis).toBe(fixture.template.instance); + fixture.cleanup(); + }); + + it('onCreated runs with this === element', async () => { + let capturedThis; + const fixture = await mountTemplate({ + onCreated() { + capturedThis = this; + }, + }); + expect(capturedThis).toBe(fixture.host); + fixture.cleanup(); + }); + + it('onRendered runs with this === element', async () => { + let capturedThis; + const fixture = await mountTemplate({ + onRendered() { + capturedThis = this; + }, + }); + fixture.template.render(); + await new Promise(r => setTimeout(r, 5)); + expect(capturedThis).toBe(fixture.host); + fixture.cleanup(); + }); + + it('event handlers run with this === matched target element', async () => { + let capturedThis; + const fixture = await mountForEvents({ + hostHTML: '', + events: { + 'click .btn'() { + capturedThis = this; + }, + }, + }); + const btn = fixture.host.querySelector('.btn'); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true, cancelable: true })); + expect(capturedThis).toBe(btn); + fixture.cleanup(); + }); + }); + + /******************************* + Cached callParams + *******************************/ + + describe('call() — cached callParams', () => { + it('builds this.callParams once at the end of initialize()', async () => { + const fixture = await mountTemplate({}); + expect(fixture.template.callParams).toBeDefined(); + expect(typeof fixture.template.callParams).toBe('object'); + fixture.cleanup(); + }); + + it('reuses the same callParams reference across plain calls', async () => { + const observed = []; + const fixture = await mountTemplate({}); + fixture.template.call((p) => observed.push(p)); + fixture.template.call((p) => observed.push(p)); + expect(observed.length).toBe(2); + expect(observed[0]).toBe(observed[1]); + expect(observed[0]).toBe(fixture.template.callParams); + fixture.cleanup(); + }); + + it('reuses bound function references (self, $, $$) across calls', async () => { + const observed = []; + const fixture = await mountTemplate({}); + fixture.template.call((p) => observed.push(p)); + fixture.template.call((p) => observed.push(p)); + expect(observed[0].self).toBe(observed[1].self); + expect(observed[0].$).toBe(observed[1].$); + expect(observed[0].$$).toBe(observed[1].$$); + fixture.cleanup(); + }); + + it('produces a fresh merged object per call when additionalData is supplied', async () => { + const observed = []; + const fixture = await mountTemplate({}); + fixture.template.call((p) => observed.push(p), { additionalData: { x: 1 } }); + fixture.template.call((p) => observed.push(p), { additionalData: { x: 2 } }); + expect(observed[0]).not.toBe(observed[1]); + expect(observed[0].x).toBe(1); + expect(observed[1].x).toBe(2); + // self is preserved across the merged objects + expect(observed[0].self).toBe(observed[1].self); + fixture.cleanup(); + }); + }); + + /******************************* + Lifecycle Hook Delivery + *******************************/ + + describe('lifecycle hook delivery', () => { + it('captures bound $ from onCreated and scopes it to renderRoot once attached', async () => { + // onCreated fires inside initialize(), before attach() sets this.renderRoot. + // The supported pattern is to capture the bound `$` reference inside + // onCreated and invoke it later (after attach completes, or from + // onRendered / event handlers). + const outsider = document.createElement('button'); + outsider.className = 'outside-btn'; + outsider.textContent = 'outside'; + document.body.appendChild(outsider); + + const host = document.createElement('div'); + host.innerHTML = ''; + document.body.appendChild(host); + + let captured$; + const tpl = new Template({ + template: '
    ', + renderingEngine: realEngine, + element: host, + onCreated({ $ }) { + captured$ = $; + }, + }); + tpl.initialize(); + await tpl.attach(host); + + expect(typeof captured$).toBe('function'); + const foundInside = captured$('.inside-btn'); + const foundOutside = captured$('.outside-btn'); + expect(foundInside.length).toBe(1); + expect(foundInside[0]).toBe(host.querySelector('.inside-btn')); + expect(foundOutside.length).toBe(0); + + tpl.onDestroyed(); + host.remove(); + outsider.remove(); + }); + + it('delivers self, state, and settings to onRendered', async () => { + let captured; + const fixture = await mountTemplate({ + defaultState: { count: 3 }, + createComponent() { + return { tag: 'instance-marker' }; + }, + onRendered(params) { + captured = { + self: params.self, + state: params.state, + settings: params.settings, + }; + }, + }); + fixture.template.render(); + await new Promise(r => setTimeout(r, 5)); + + expect(captured.self).toBe(fixture.template.instance); + expect(captured.self.tag).toBe('instance-marker'); + expect(captured.state).toBe(fixture.template.state); + expect(captured.state.count).toBeInstanceOf(Signal); + expect(captured.state.count.get()).toBe(3); + expect(captured.settings).toBe(fixture.template.settings || fixture.host.settings); + + fixture.cleanup(); + }); + + it('does not put event in lifecycle args (only event-callbacks have it)', async () => { + let captured; + const fixture = await mountTemplate({ + onRendered(params) { + captured = params; + }, + }); + fixture.template.render(); + await new Promise(r => setTimeout(r, 5)); + expect(captured).toBeDefined(); + expect(captured.event).toBeUndefined(); + expect('event' in captured).toBe(false); + fixture.cleanup(); + }); + + it('keeps self accessible inside onDestroyed and reflects destroyed=true', async () => { + let capturedSelf; + let capturedDestroyedFlag; + const fixture = await mountTemplate({ + createComponent() { + return { tag: 'still-here' }; + }, + onDestroyed({ self }) { + capturedSelf = self; + // by the time the user callback runs, the wrapper has + // already flipped destroyed=true + capturedDestroyedFlag = fixture.template.destroyed; + }, + }); + const instanceRef = fixture.template.instance; + fixture.cleanup(); + expect(capturedSelf).toBe(instanceRef); + expect(capturedSelf.tag).toBe('still-here'); + expect(capturedDestroyedFlag).toBe(true); + }); + + it('reflects template.isHydrating set by the hydration path inside onCreated', async () => { + let snapshotDuring; + const host = document.createElement('div'); + document.body.appendChild(host); + const tpl = new Template({ + template: '
    ', + renderingEngine: realEngine, + element: host, + onCreated({ isHydrating }) { + snapshotDuring = isHydrating; + }, + }); + // Set the hydration flag BEFORE initialize() so onCreated reads true. + tpl.isHydrating = true; + tpl.initialize(); + expect(snapshotDuring).toBe(true); + tpl.isHydrating = false; + expect(tpl.callParams.isHydrating).toBe(false); + tpl.onDestroyed(); + host.remove(); + }); + + it('exposes findParent and findChild as bound and callable from inside onCreated', async () => { + let capturedFindParent; + let capturedFindChild; + let parentReturn; + let childReturn; + const fixture = await mountTemplate({ + onCreated({ findParent, findChild }) { + capturedFindParent = findParent; + capturedFindChild = findChild; + parentReturn = findParent('any-name'); + childReturn = findChild('any-name'); + }, + }); + expect(typeof capturedFindParent).toBe('function'); + expect(typeof capturedFindChild).toBe('function'); + // No parent/child wired in this fixture — both walks return undefined. + expect(parentReturn).toBeUndefined(); + expect(childReturn).toBeUndefined(); + fixture.cleanup(); + }); + }); +}); diff --git a/packages/templating/test/browser/data-context-render.test.js b/packages/templating/test/browser/data-context-render.test.js new file mode 100644 index 000000000..b91c396df --- /dev/null +++ b/packages/templating/test/browser/data-context-render.test.js @@ -0,0 +1,315 @@ +import { Signal } from '@semantic-ui/reactivity'; +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template } from '@semantic-ui/templating'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +// Construct a Template, initialize, and attach to a host in document.body. +// Spies are taken AFTER initialize() so the first render() call is observed. +async function mountTemplate({ template = '
    ', ...opts } = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + const tpl = new Template({ + template, + renderingEngine: realEngine, + element: host, + ...opts, + }); + tpl.initialize(); + await tpl.attach(host); + return { + host, + template: tpl, + cleanup: () => { + try { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + } + catch (_) {} + if (host.parentNode) { + host.parentNode.removeChild(host); + } + }, + }; +} + +/******************************* + First render +*******************************/ + +describe('Template render — first call', () => { + it('lazily initializes on first render', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + defaultState: { count: 0 }, + }); + try { + expect(template.initialized).toBeUndefined(); + template.render(); + expect(template.initialized).toBe(true); + } + finally { + if (template.initialized && !template.destroyed) { + template.onDestroyed(); + } + } + }); + + it('calls renderer.setData once per render call', async () => { + const { template, cleanup } = await mountTemplate({ + defaultState: { count: 0 }, + }); + const setDataSpy = vi.spyOn(template.renderer, 'setData'); + try { + template.render(); + expect(setDataSpy).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('calls renderer.render exactly once on first render', async () => { + const { template, cleanup } = await mountTemplate({ + defaultState: { count: 0 }, + }); + const renderSpy = vi.spyOn(template.renderer, 'render'); + try { + template.render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('passes the merged data context to renderer.setData', async () => { + const { template, cleanup } = await mountTemplate({ + data: { name: 'jack' }, + defaultState: { count: 7 }, + }); + try { + template.render(); + // Real Renderer's setData calls assignInPlace into this.data; the + // merged dataContext keys land on template.renderer.data after the call. + expect(template.renderer.data.name).toBe('jack'); + expect(template.renderer.data.count).toBeInstanceOf(Signal); + expect(template.renderer.data.count.peek()).toBe(7); + } + finally { + cleanup(); + } + }); + + it('returns the renderer.render() output as this.html', async () => { + const { template, cleanup } = await mountTemplate(); + try { + const html = template.render(); + expect(html).toBeInstanceOf(DocumentFragment); + expect(template.html).toBe(html); + } + finally { + cleanup(); + } + }); + + it('flips rendered to true via markRendered before returning', async () => { + const { template, cleanup } = await mountTemplate(); + try { + expect(template.rendered).toBe(false); + template.render(); + expect(template.rendered).toBe(true); + } + finally { + cleanup(); + } + }); + + it('schedules onRendered via setTimeout(0) on client', async () => { + const onRendered = vi.fn(); + const { template, cleanup } = await mountTemplate({ onRendered }); + try { + template.render(); + expect(onRendered).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 10)); + expect(onRendered).toHaveBeenCalled(); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + Re-render +*******************************/ + +describe('Template render — re-call', () => { + it('does not call renderer.render again when already rendered', async () => { + const { template, cleanup } = await mountTemplate({ + defaultState: { count: 0 }, + }); + const renderSpy = vi.spyOn(template.renderer, 'render'); + try { + template.render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + template.dataReplaced = false; + template.render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); + + it('calls renderer.setData on every render so the engine sees latest data', async () => { + const { template, cleanup } = await mountTemplate({ + defaultState: { count: 0 }, + }); + const setDataSpy = vi.spyOn(template.renderer, 'setData'); + try { + template.render(); + template.render(); + template.render(); + expect(setDataSpy).toHaveBeenCalledTimes(3); + } + finally { + cleanup(); + } + }); + + it('calls bumpDataVersion when dataReplaced is true on re-render', async () => { + const { template, cleanup } = await mountTemplate({ + data: { a: 1 }, + }); + const bumpSpy = vi.spyOn(template.renderer, 'bumpDataVersion'); + try { + template.render(); + const firstBumps = bumpSpy.mock.calls.length; + template.dataReplaced = true; + template.render(); + expect(bumpSpy.mock.calls.length).toBe(firstBumps + 1); + } + finally { + cleanup(); + } + }); + + it('does not call bumpDataVersion when dataReplaced is false', async () => { + const { template, cleanup } = await mountTemplate({ + data: { a: 1 }, + }); + const bumpSpy = vi.spyOn(template.renderer, 'bumpDataVersion'); + try { + template.render(); + const firstBumps = bumpSpy.mock.calls.length; + template.dataReplaced = false; + template.render(); + expect(bumpSpy.mock.calls.length).toBe(firstBumps); + } + finally { + cleanup(); + } + }); + + it('clears dataReplaced after using it on re-render', async () => { + const { template, cleanup } = await mountTemplate({ + data: { a: 1 }, + }); + try { + template.render(); + template.dataReplaced = true; + template.render(); + expect(template.dataReplaced).toBe(false); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + additionalData override +*******************************/ + +describe('Template render — additionalData override', () => { + it('lets additionalData override getDataContext on collision', async () => { + const { template, cleanup } = await mountTemplate({ + data: { index: 0 }, + defaultState: { index: 1 }, + }); + try { + template.render({ index: 5 }); + expect(template.renderer.data.index).toBe(5); + } + finally { + cleanup(); + } + }); + + it('persists additionalData keys onto template.data via assignInPlace', async () => { + const { template, cleanup } = await mountTemplate({ + data: { name: 'jack' }, + }); + try { + template.render({ extra: 'value' }); + expect(template.data.extra).toBe('value'); + } + finally { + cleanup(); + } + }); +}); + +/******************************* + setDataContext-driven re-render +*******************************/ + +describe('Template — setDataContext + render coordination', () => { + it('forces renderer.render again with default rerender:true', async () => { + const { template, cleanup } = await mountTemplate({ + data: { a: 1 }, + }); + const renderSpy = vi.spyOn(template.renderer, 'render'); + try { + template.render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + template.setDataContext({ a: 2 }); + template.render(); + expect(renderSpy).toHaveBeenCalledTimes(2); + } + finally { + cleanup(); + } + }); + + it('skips renderer.render when called with rerender:false but still bumps data version', async () => { + const { template, cleanup } = await mountTemplate({ + data: { a: 1 }, + }); + const renderSpy = vi.spyOn(template.renderer, 'render'); + const bumpSpy = vi.spyOn(template.renderer, 'bumpDataVersion'); + try { + template.render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + template.setDataContext({ a: 2 }, { rerender: false }); + template.render(); + // rendered stays true → render() takes the else-if branch + expect(renderSpy).toHaveBeenCalledTimes(1); + // dataReplaced was set by the setDataContext mutation → bump fires + expect(bumpSpy).toHaveBeenCalledTimes(1); + } + finally { + cleanup(); + } + }); +}); diff --git a/packages/templating/test/browser/dom-scoping.test.js b/packages/templating/test/browser/dom-scoping.test.js new file mode 100644 index 000000000..d1bb1d1c1 --- /dev/null +++ b/packages/templating/test/browser/dom-scoping.test.js @@ -0,0 +1,544 @@ +// Template's renderRoot-scoped query helpers ($, $$, isNodeInTemplate) +// and the containment substrate they share with the events DSL. All +// tests run in the browser project — shadow DOM, attachShadow, and +// compareDocumentPosition need a real browser; jsdom support is +// partial and unreliable. +// +// Methodology: +// - mountTemplate attaches a real Template + Renderer to a host element. +// When `target: 'shadow'` (default) the renderRoot is an open shadow +// root; when `target: 'light'` the renderRoot is the host itself. +// Tests are parameterized over both — DOM scoping is the one surface +// where shadow vs light boundary semantics are the whole point. +// - The template ('
    ') is intentionally minimal. We don't call +// render(), so the renderRoot stays empty and each test populates it +// manually with the elements it needs. +// - For isNodeInTemplate's startNode/endNode branch, sentinels are +// mutated on the Template instance directly (matches what the +// renderer's DynamicRegion does when wiring subtemplates). + +import { afterEach, describe, expect, it } from 'vitest'; + +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; + +import { Template } from '../../src/template.js'; + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +async function mountTemplate({ template = '
    ', target = 'shadow', ...opts } = {}) { + const host = document.createElement('div'); + const renderRoot = target === 'shadow' ? host.attachShadow({ mode: 'open' }) : host; + document.body.appendChild(host); + const tpl = new Template({ + template, + renderingEngine: realEngine, + element: host, + ...opts, + }); + tpl.initialize(); + await tpl.attach(renderRoot); + return { + host, + renderRoot, + template: tpl, + cleanup: () => { + try { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + } + catch (_) {} + if (host.parentNode) { + host.parentNode.removeChild(host); + } + }, + }; +} + +const RENDER_TARGETS = [ + { name: 'light', target: 'light' }, + { name: 'shadow', target: 'shadow' }, +]; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +RENDER_TARGETS.forEach(({ name, target }) => { + describe(`Template — DOM scoping (${name})`, () => { + let fixture; + let cleanups = []; + + afterEach(() => { + if (fixture && fixture.cleanup) { + try { + fixture.cleanup(); + } + catch (_) {} + } + cleanups.forEach(fn => { + try { + fn(); + } + catch (_) {} + }); + cleanups = []; + fixture = null; + }); + + /******************************* + $ — renderRoot-scoped query + *******************************/ + + describe('$ — renderRoot-scoped query', () => { + it("finds elements in the component's own renderRoot", async () => { + fixture = await mountTemplate({ target }); + const div = document.createElement('div'); + div.className = 'match'; + fixture.renderRoot.appendChild(div); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(1); + expect(result[0]).toBe(div); + }); + + it('returns empty when nothing matches in the renderRoot', async () => { + fixture = await mountTemplate({ target }); + const result = fixture.template.$('.no-such-thing'); + expect(result.length).toBe(0); + }); + + it("does not pierce a nested child component's shadow root", async () => { + fixture = await mountTemplate({ target }); + const inner = document.createElement('div'); + const innerShadow = inner.attachShadow({ mode: 'open' }); + const buried = document.createElement('span'); + buried.className = 'match'; + buried.textContent = 'buried'; + innerShadow.appendChild(buried); + fixture.renderRoot.appendChild(inner); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(0); + }); + + it('does not match elements outside the renderRoot', async () => { + fixture = await mountTemplate({ target }); + const sibling = document.createElement('div'); + sibling.className = 'match'; + document.body.appendChild(sibling); + cleanups.push(() => sibling.remove()); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(0); + }); + }); + + /******************************* + $$ — shadow-piercing query + *******************************/ + + describe('$$ — shadow-piercing query', () => { + it('finds elements in the own renderRoot (matches $ for that case)', async () => { + fixture = await mountTemplate({ target }); + const div = document.createElement('div'); + div.className = 'match'; + fixture.renderRoot.appendChild(div); + + const result = fixture.template.$$('.match'); + expect(result.length).toBe(1); + expect(result[0]).toBe(div); + }); + + it("pierces into a nested child component's shadow root", async () => { + fixture = await mountTemplate({ target }); + const inner = document.createElement('div'); + const innerShadow = inner.attachShadow({ mode: 'open' }); + const buried = document.createElement('span'); + buried.className = 'match'; + innerShadow.appendChild(buried); + fixture.renderRoot.appendChild(inner); + + const result = fixture.template.$$('.match'); + const matched = Array.from(result); + expect(matched).toContain(buried); + }); + + it('finds elements at multiple nesting levels of shadow roots', async () => { + fixture = await mountTemplate({ target }); + + // renderRoot → outerHost (shadow) → innerHost (shadow) → .match + const outerHost = document.createElement('div'); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + const innerHost = document.createElement('div'); + const innerShadow = innerHost.attachShadow({ mode: 'open' }); + const deepest = document.createElement('span'); + deepest.className = 'match'; + innerShadow.appendChild(deepest); + outerShadow.appendChild(innerHost); + fixture.renderRoot.appendChild(outerHost); + + // Sibling at the top level too. + const top = document.createElement('span'); + top.className = 'match'; + fixture.renderRoot.appendChild(top); + + const result = Array.from(fixture.template.$$('.match')); + expect(result).toContain(top); + expect(result).toContain(deepest); + expect(result.length).toBeGreaterThanOrEqual(2); + }); + }); + + /******************************* + Special selectors → document + *******************************/ + + describe('special selectors escape to document', () => { + it('$("body") returns document.body even when called from inside a component', async () => { + fixture = await mountTemplate({ target }); + const result = fixture.template.$('body'); + expect(result.length).toBe(1); + expect(result[0]).toBe(document.body); + }); + + it('$("html") returns document.documentElement', async () => { + fixture = await mountTemplate({ target }); + const result = fixture.template.$('html'); + expect(result.length).toBe(1); + expect(result[0]).toBe(document.documentElement); + }); + + // 'document' is in template.js's special-selector list (rebinds root + // to document) but Query has no matching special-case for the literal + // selector 'document' (unlike 'window'/'globalThis'). The call ends + // up running `document.querySelectorAll('document')` — invalid as a + // CSS tag selector — and returns zero matches. Authors who want the + // document object should use `{ root: document }` or query for + // 'html'/'body' instead. + it('$("document") rebinds root to document but yields no matches', async () => { + fixture = await mountTemplate({ target }); + const result = fixture.template.$('document'); + expect(result.length).toBe(0); + }); + + it('$("body") result is not filtered by isNodeInTemplate', async () => { + // body is plainly outside the renderRoot. If filterTemplate were applied, + // the result would be filtered out. Rebinding the root to document + // also bypasses the filter. + fixture = await mountTemplate({ target }); + const result = fixture.template.$('body'); + expect(result.length).toBe(1); + expect(result[0]).toBe(document.body); + }); + + // 'window' is handled by Query's own `inArray(selector, ['window', + // 'globalThis'])` branch, NOT template.js's special-selector list + // (which is ['body', 'document', 'html'] only). Cross-package + // coordination — if Query's special-case is refactored, in-component + // $('window') silently breaks. + it('$("window") returns the global proxy via underlying Query', async () => { + fixture = await mountTemplate({ target }); + const result = fixture.template.$('window'); + expect(result.length).toBe(1); + const { Query } = await import('@semantic-ui/query'); + expect(Query.isWindow(result[0])).toBe(true); + }); + + it('$ inside onCreated falls through to document so authors can query the rest of the page', () => { + // onCreated fires before attach() wires renderRoot, so this.renderRoot + // is undefined. The fallback used to be globalThis (Window), which has + // no querySelectorAll and threw a hard TypeError. document covers the + // user's actual intent: "query the surrounding page from onCreated." + const sentinel = document.createElement('div'); + sentinel.id = 'oncreated-page-sentinel'; + document.body.appendChild(sentinel); + let observed; + const template = new Template({ + template: '
    ', + renderingEngine: { renderer: Renderer, serverRenderer: ServerRenderer }, + onCreated: ({ $ }) => { + observed = $('#oncreated-page-sentinel'); + }, + }); + try { + template.initialize(); + expect(observed).toBeDefined(); + expect(observed.length).toBe(1); + expect(observed[0]).toBe(sentinel); + } + finally { + sentinel.remove(); + template.onDestroyed(); + } + }); + }); + + /******************************* + filterTemplate: false + *******************************/ + + describe('filterTemplate: false (internal opt-out)', () => { + it('returns the raw query without applying isNodeInTemplate filter', async () => { + fixture = await mountTemplate({ target }); + const div = document.createElement('div'); + div.className = 'match'; + fixture.renderRoot.appendChild(div); + + // Force isNodeInTemplate to return false for everything by setting + // a degenerate range — sentinels positioned such that no DOM node + // can be strictly between them. + const startNode = document.createTextNode(''); + const endNode = document.createTextNode(''); + fixture.renderRoot.appendChild(startNode); + fixture.renderRoot.appendChild(endNode); + fixture.template.startNode = startNode; + fixture.template.endNode = endNode; + + // Default filterTemplate:true excludes div via the range filter. + expect(fixture.template.$('.match').length).toBe(0); + // filterTemplate:false returns the raw query. + const raw = fixture.template.$('.match', { filterTemplate: false }); + expect(raw.length).toBe(1); + expect(raw[0]).toBe(div); + }); + }); + + /******************************* + isNodeInTemplate (no markers) + *******************************/ + + describe('isNodeInTemplate — web component (no range markers)', () => { + it("returns true for an element rendered inside the component's renderRoot", async () => { + fixture = await mountTemplate({ target }); + const div = document.createElement('div'); + fixture.renderRoot.appendChild(div); + expect(fixture.template.isNodeInTemplate(div)).toBe(true); + }); + + it('returns true for a deeply nested element in own subtree', async () => { + fixture = await mountTemplate({ target }); + const a = document.createElement('div'); + const b = document.createElement('div'); + const c = document.createElement('span'); + a.appendChild(b); + b.appendChild(c); + fixture.renderRoot.appendChild(a); + expect(fixture.template.isNodeInTemplate(c)).toBe(true); + }); + + // For top-level Templates without sentinels, isNodeInRange short- + // circuits to true before any node check. The function's actual + // contract is narrower than "is this node a descendant of my + // renderRoot": it's "given a node that bubbled to my event listener, + // is it in my range" — and event targets that reach the listener + // are by construction inside the renderRoot. + it('returns true for a sibling on the document outside the renderRoot', async () => { + fixture = await mountTemplate({ target }); + const sibling = document.createElement('div'); + document.body.appendChild(sibling); + cleanups.push(() => sibling.remove()); + + expect(fixture.template.isNodeInTemplate(sibling)).toBe(true); + }); + + it('returns true for a fully detached node when no sentinels are set', async () => { + fixture = await mountTemplate({ target }); + const detached = document.createElement('div'); + // Never appended to anything — parentNode is null, host is undefined. + // Walk dies immediately; range short-circuit returns true. + expect(fixture.template.isNodeInTemplate(detached)).toBe(true); + }); + }); + + /******************************* + isNodeInTemplate (sentinels set) + *******************************/ + + describe('isNodeInTemplate — subtemplate range', () => { + // Mount and lay out: before, , middle, , after. + async function mountWithSentinelRange() { + const f = await mountTemplate({ target }); + + const before = document.createElement('div'); + before.className = 'before'; + const startNode = document.createTextNode(''); + const middle = document.createElement('div'); + middle.className = 'middle'; + const endNode = document.createTextNode(''); + const after = document.createElement('div'); + after.className = 'after'; + + f.renderRoot.appendChild(before); + f.renderRoot.appendChild(startNode); + f.renderRoot.appendChild(middle); + f.renderRoot.appendChild(endNode); + f.renderRoot.appendChild(after); + + f.template.startNode = startNode; + f.template.endNode = endNode; + + return { fixture: f, before, startNode, middle, endNode, after }; + } + + it('returns true for a node strictly between startNode and endNode', async () => { + const ctx = await mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.middle)).toBe(true); + }); + + it('returns false for a node before startNode', async () => { + const ctx = await mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.before)).toBe(false); + }); + + it('returns false for a node after endNode', async () => { + const ctx = await mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.after)).toBe(false); + }); + + it('returns false for a detached node even when sentinels are set', async () => { + const ctx = await mountWithSentinelRange(); + fixture = ctx.fixture; + const detached = document.createElement('div'); + expect(fixture.template.isNodeInTemplate(detached)).toBe(false); + }); + }); + + /******************************* + $ + range filter + *******************************/ + + describe('$ post-filters via isNodeInTemplate when root === renderRoot', () => { + it('with sentinels set, $ returns only nodes strictly between them', async () => { + fixture = await mountTemplate({ target }); + + const before = document.createElement('div'); + before.className = 'match'; + before.textContent = 'before'; + const startNode = document.createTextNode(''); + const middle = document.createElement('div'); + middle.className = 'match'; + middle.textContent = 'middle'; + const endNode = document.createTextNode(''); + const after = document.createElement('div'); + after.className = 'match'; + after.textContent = 'after'; + + fixture.renderRoot.appendChild(before); + fixture.renderRoot.appendChild(startNode); + fixture.renderRoot.appendChild(middle); + fixture.renderRoot.appendChild(endNode); + fixture.renderRoot.appendChild(after); + + fixture.template.startNode = startNode; + fixture.template.endNode = endNode; + + const result = fixture.template.$('.match'); + expect(result.length).toBe(1); + expect(result[0]).toBe(middle); + }); + }); + }); +}); + +/******************************* + Shadow-only boundary semantics +*******************************/ + +// These behaviors only manifest when the renderRoot is a real ShadowRoot — +// either because the test setup needs a host element distinct from the +// renderRoot (so light DOM children of the host can be visible from +// document but not from $) or because the host-walking through .host +// chains requires shadow boundaries. + +describe('Template — DOM scoping (shadow only)', () => { + let fixture; + let cleanups = []; + + afterEach(() => { + if (fixture && fixture.cleanup) { + try { + fixture.cleanup(); + } + catch (_) {} + } + cleanups.forEach(fn => { + try { + fn(); + } + catch (_) {} + }); + cleanups = []; + fixture = null; + }); + + it('$ does not find elements that live in the light DOM of the host', async () => { + // A child in light DOM (host's children, not slotted into the shadow tree) + // is not visible from `querySelectorAll` rooted at the shadow root. + fixture = await mountTemplate({ target: 'shadow' }); + const lightChild = document.createElement('div'); + lightChild.className = 'match'; + lightChild.textContent = 'light'; + fixture.host.appendChild(lightChild); + + const result = fixture.template.$('.match'); + expect(result.length).toBe(0); + }); + + it('isNodeInTemplate returns true for an element inside a nested shadow root', async () => { + // The getRootChild walk crosses shadow boundaries upward via `node.host` + // so events bubbling out of a nested child component can be attributed + // to the parent template. + fixture = await mountTemplate({ target: 'shadow' }); + const innerHost = document.createElement('div'); + const innerShadow = innerHost.attachShadow({ mode: 'open' }); + const deep = document.createElement('span'); + innerShadow.appendChild(deep); + fixture.renderRoot.appendChild(innerHost); + + expect(fixture.template.isNodeInTemplate(deep)).toBe(true); + }); + + // Sentinels themselves are not in the range. Depends on + // compareDocumentPosition's strict bitwise comparison returning the + // FOLLOWING/PRECEDING bits unset for self-comparison. + describe('sentinel exclusivity', () => { + async function mountWithSentinelRange() { + const f = await mountTemplate({ target: 'shadow' }); + + const before = document.createElement('div'); + const startNode = document.createTextNode(''); + const middle = document.createElement('div'); + const endNode = document.createTextNode(''); + const after = document.createElement('div'); + + f.renderRoot.appendChild(before); + f.renderRoot.appendChild(startNode); + f.renderRoot.appendChild(middle); + f.renderRoot.appendChild(endNode); + f.renderRoot.appendChild(after); + + f.template.startNode = startNode; + f.template.endNode = endNode; + + return { fixture: f, before, startNode, middle, endNode, after }; + } + + it('returns false when node === startNode', async () => { + const ctx = await mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.startNode)).toBe(false); + }); + + it('returns false when node === endNode', async () => { + const ctx = await mountWithSentinelRange(); + fixture = ctx.fixture; + expect(fixture.template.isNodeInTemplate(ctx.endNode)).toBe(false); + }); + }); +}); diff --git a/packages/templating/test/browser/events.test.js b/packages/templating/test/browser/events.test.js new file mode 100644 index 000000000..942021768 --- /dev/null +++ b/packages/templating/test/browser/events.test.js @@ -0,0 +1,1115 @@ +// Browser tests for the events DSL: selector grammar, bubble-map rewrites, the +// four dialects (default delegation, deep, global, bind), handler-arg shape, +// return-value contract, attachEvent/dispatchEvent helpers, and lifecycle +// teardown. Most behaviors are parameterized across light and shadow render +// targets; shadow-only boundary tests live at the bottom. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template } from '@semantic-ui/templating'; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +/******************************* + Local Test Helpers +*******************************/ + +const RENDER_TARGETS = [ + { name: 'light', target: 'light' }, + { name: 'shadow', target: 'shadow' }, +]; + +async function mountTemplate({ + template = '
    ', + events, + keys, + target = 'shadow', + ...opts +} = {}) { + const host = document.createElement('div'); + const renderRoot = target === 'shadow' + ? host.attachShadow({ mode: 'open' }) + : host; + document.body.appendChild(host); + const tpl = new Template({ + template, + renderingEngine: { renderer: Renderer, serverRenderer: ServerRenderer }, + element: host, + events, + keys, + ...opts, + }); + tpl.initialize(); + await tpl.attach(renderRoot); + return { + host, + renderRoot, + template: tpl, + cleanup: () => { + try { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + } + catch (_) {} + if (host.parentNode) { + host.parentNode.removeChild(host); + } + }, + }; +} + +function clickOn(element, init = {}) { + element.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +function fireEvent(element, eventName, init = {}) { + element.dispatchEvent( + new Event(eventName, { + bubbles: true, + composed: true, + cancelable: true, + ...init, + }), + ); +} + +function fireCustomEvent(element, eventName, detail = {}, init = {}) { + element.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + composed: true, + cancelable: true, + detail, + ...init, + }), + ); +} + +/******************************* + Parser Grammar +*******************************/ + +describe('events DSL — selector grammar', () => { + it('parses a single event with a single selector', () => { + const template = new Template({}); + const parsed = template.parseEventString('click .submit'); + expect(parsed).toEqual([ + { eventName: 'click', eventType: 'delegate', selector: '.submit' }, + ]); + }); + + it('parses a comma-list of events sharing one selector', () => { + const template = new Template({}); + const parsed = template.parseEventString('mouseup, mouseleave .selector'); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatchObject({ eventName: 'mouseup', selector: '.selector' }); + // mouseleave is rewritten to mouseout via bubble map + expect(parsed[1]).toMatchObject({ eventName: 'mouseout', selector: '.selector' }); + }); + + it('parses a comma-list of selectors sharing one event', () => { + const template = new Template({}); + const parsed = template.parseEventString('click .a, .b'); + expect(parsed).toHaveLength(2); + expect(parsed[0]).toMatchObject({ eventName: 'click', selector: '.a' }); + expect(parsed[1]).toMatchObject({ eventName: 'click', selector: '.b' }); + }); + + it('binds one handler to the cross product of comma-listed events and selectors', () => { + const template = new Template({}); + const parsed = template.parseEventString('click, mouseup .a, .b'); + expect(parsed).toHaveLength(4); + const pairs = parsed.map(({ eventName, selector }) => `${eventName}|${selector}`).sort(); + expect(pairs).toEqual([ + 'click|.a', + 'click|.b', + 'mouseup|.a', + 'mouseup|.b', + ]); + }); + + it('treats an empty selector as component-wide when no selector is given', () => { + const template = new Template({}); + const parsed = template.parseEventString('click'); + expect(parsed).toEqual([ + { eventName: 'click', eventType: 'delegate', selector: '' }, + ]); + }); + + it('strips the `deep` keyword and sets eventType', () => { + const template = new Template({}); + const parsed = template.parseEventString('deep click .item'); + expect(parsed).toEqual([ + { eventName: 'click', eventType: 'deep', selector: '.item' }, + ]); + }); + + it('strips the `global` keyword and sets eventType', () => { + const template = new Template({}); + const parsed = template.parseEventString('global hashchange window'); + expect(parsed).toEqual([ + { eventName: 'hashchange', eventType: 'global', selector: 'window' }, + ]); + }); + + it('strips the `bind` keyword and sets eventType', () => { + const template = new Template({}); + const parsed = template.parseEventString('bind customevent some-component'); + expect(parsed).toEqual([ + { eventName: 'customevent', eventType: 'bind', selector: 'some-component' }, + ]); + }); + + it('does not strip a keyword embedded in a longer event name', () => { + // `'deepclick'` should be parsed as the literal eventName, not as + // `deep` + `click`. Keyword detection requires a word boundary. + const template = new Template({}); + const parsed = template.parseEventString('deepclick .item'); + expect(parsed).toEqual([ + { eventName: 'deepclick', eventType: 'delegate', selector: '.item' }, + ]); + }); +}); + +/******************************* + Bubble Map Mapping +*******************************/ + +describe('events DSL — non-bubbling event mapping', () => { + it('rewrites blur as focusout so delegation can hear it', () => { + const template = new Template({}); + const parsed = template.parseEventString('blur .input'); + expect(parsed[0].eventName).toBe('focusout'); + }); + + it('rewrites focus as focusin so delegation can hear it', () => { + const template = new Template({}); + const parsed = template.parseEventString('focus .input'); + expect(parsed[0].eventName).toBe('focusin'); + }); + + it('rewrites mouseenter as mouseover', () => { + const template = new Template({}); + const parsed = template.parseEventString('mouseenter .area'); + expect(parsed[0].eventName).toBe('mouseover'); + }); + + it('rewrites mouseleave as mouseout', () => { + const template = new Template({}); + const parsed = template.parseEventString('mouseleave .area'); + expect(parsed[0].eventName).toBe('mouseout'); + }); + + it('rewrites load as DOMContentLoaded', () => { + const template = new Template({}); + const parsed = template.parseEventString('load'); + expect(parsed[0].eventName).toBe('DOMContentLoaded'); + }); + + it('rewrites unload as beforeunload', () => { + const template = new Template({}); + const parsed = template.parseEventString('unload'); + expect(parsed[0].eventName).toBe('beforeunload'); + }); +}); + +/******************************* + Parameterized: light + shadow +*******************************/ + +RENDER_TARGETS.forEach(({ name, target }) => { + describe(name, () => { + /******************************* + Default-Mode Delegation + *******************************/ + + describe('events DSL — default-mode delegation', () => { + it('fires on elements matching the selector when delegated to the render root', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const btn = fixture.renderRoot.querySelector('.btn'); + clickOn(btn); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('binds one handler to multiple events when events are comma-separated', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click, mouseup .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const btn = fixture.renderRoot.querySelector('.btn'); + clickOn(btn); + fireEvent(btn, 'mouseup'); + expect(handler).toHaveBeenCalledTimes(2); + } + finally { + fixture.cleanup(); + } + }); + + it('binds one handler to multiple selectors when selectors are comma-separated', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click .a, .b': handler }, + }); + fixture.renderRoot.innerHTML = '
    A
    B
    '; + try { + clickOn(fixture.renderRoot.querySelector('.a')); + clickOn(fixture.renderRoot.querySelector('.b')); + expect(handler).toHaveBeenCalledTimes(2); + } + finally { + fixture.cleanup(); + } + }); + + it('skips mouseover when relatedTarget is a descendant of the target', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'mouseenter .area': handler }, + }); + fixture.renderRoot.innerHTML = '
    child
    '; + try { + const area = fixture.renderRoot.querySelector('.area'); + const inner = fixture.renderRoot.querySelector('.inner'); + // Crossing into .area from outside fires. + area.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + composed: true, + relatedTarget: document.body, + }), + ); + expect(handler).toHaveBeenCalledTimes(1); + + // Movement entirely within .area is filtered out. + area.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + composed: true, + relatedTarget: inner, + }), + ); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Deep Keyword + *******************************/ + + describe('events DSL — deep keyword (basic)', () => { + it('fires on a direct match inside the own template with isDeep false', async () => { + let receivedIsDeep; + const fixture = await mountTemplate({ + target, + events: { + 'deep click .btn'({ isDeep }) { + receivedIsDeep = isDeep; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(receivedIsDeep).toBe(false); + } + finally { + fixture.cleanup(); + } + }); + + it('delivers isDeep as a boolean to the handler', async () => { + let receivedIsDeep; + const fixture = await mountTemplate({ + target, + events: { + 'deep click .btn'({ isDeep }) { + receivedIsDeep = isDeep; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(typeof receivedIsDeep).toBe('boolean'); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Global Keyword + *******************************/ + + describe('events DSL — global keyword', () => { + it('attaches listeners to window for global window events', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'global hashchange window': handler }, + }); + try { + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('removes the global listener when the template is destroyed', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'global hashchange window': handler }, + }); + fixture.cleanup(); + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(handler).not.toHaveBeenCalled(); + }); + + it('defaults to window when no selector is given', async () => { + // `'global hashchange'` (no selector) should default to window so + // authors don't have to repeat the obvious `window` for the typical + // global event use cases (scroll/resize/hashchange). + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'global hashchange': handler }, + }); + try { + window.dispatchEvent(new HashChangeEvent('hashchange')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Bind Keyword + *******************************/ + + describe('events DSL — bind keyword', () => { + it('does not bind to elements until first render fires onRenderOnce', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'bind ping .target': handler }, + }); + fixture.renderRoot.innerHTML = '
    '; + try { + const tgt = fixture.renderRoot.querySelector('.target'); + fireCustomEvent(tgt, 'ping'); + expect(handler).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('attaches listeners directly after onRendered fires', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'bind ping .target': handler }, + }); + fixture.renderRoot.innerHTML = '
    '; + try { + if (typeof fixture.template.onRenderOnce === 'function') { + fixture.template.onRenderOnce(); + } + const tgt = fixture.renderRoot.querySelector('.target'); + fireCustomEvent(tgt, 'ping'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('hears non-bubbling CustomEvents that delegation cannot see', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'bind nobubble .target': handler }, + }); + fixture.renderRoot.innerHTML = '
    '; + try { + if (typeof fixture.template.onRenderOnce === 'function') { + fixture.template.onRenderOnce(); + } + const tgt = fixture.renderRoot.querySelector('.target'); + tgt.dispatchEvent(new CustomEvent('nobubble', { bubbles: false })); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('does not double-bind across multiple render cycles', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'bind ping .target': handler }, + }); + fixture.renderRoot.innerHTML = '
    '; + try { + if (typeof fixture.template.onRenderOnce === 'function') { + fixture.template.onRenderOnce(); + } + // Second call is a no-op — onRenderOnce wraps to noop after the first run. + const tgt = fixture.renderRoot.querySelector('.target'); + fireCustomEvent(tgt, 'ping'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Handler Arguments + *******************************/ + + describe('events DSL — handler arguments', () => { + it('passes the native event as `event`', async () => { + let received; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'({ event }) { + received = event; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(received).toBeInstanceOf(MouseEvent); + expect(received.type).toBe('click'); + } + finally { + fixture.cleanup(); + } + }); + + it('passes the matched element as `target`', async () => { + let receivedTarget; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'({ target: t }) { + receivedTarget = t; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + // Clicking the inner span should resolve the matched element to .btn. + clickOn(fixture.renderRoot.querySelector('.label')); + expect(receivedTarget).toBe(fixture.renderRoot.querySelector('.btn')); + } + finally { + fixture.cleanup(); + } + }); + + it('binds `this` to the matched element', async () => { + let receivedThis; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'() { + receivedThis = this; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const btn = fixture.renderRoot.querySelector('.btn'); + clickOn(btn); + expect(receivedThis).toBe(btn); + } + finally { + fixture.cleanup(); + } + }); + + it('parses data-* attributes as typed values in `data` (numbers, booleans, JSON)', async () => { + let receivedData; + const fixture = await mountTemplate({ + target, + events: { + 'click .btn'({ data }) { + receivedData = data; + }, + }, + }); + fixture.renderRoot.innerHTML = ` + + `; + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(receivedData.amount).toBe(5); + expect(receivedData.active).toBe(true); + expect(receivedData.config).toEqual({ x: 1 }); + expect(receivedData.name).toBe('hello'); + } + finally { + fixture.cleanup(); + } + }); + + it('merges event.detail into `data`, with detail keys overriding dataset keys', async () => { + let receivedData; + const fixture = await mountTemplate({ + target, + events: { + 'mycustom .item'({ data }) { + receivedData = data; + }, + }, + }); + fixture.renderRoot.innerHTML = '
    '; + try { + const item = fixture.renderRoot.querySelector('.item'); + fireCustomEvent(item, 'mycustom', { key: 'from-detail', extra: 'detail-only' }); + expect(receivedData.key).toBe('from-detail'); + expect(receivedData.extra).toBe('detail-only'); + expect(receivedData.only).toBe('dataset-only'); + } + finally { + fixture.cleanup(); + } + }); + + it('passes `value` from target.value when the target is a form control', async () => { + let receivedValue; + const fixture = await mountTemplate({ + target, + events: { + 'change input'({ value }) { + receivedValue = value; + }, + }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const input = fixture.renderRoot.querySelector('input'); + input.value = 'updated'; + fireEvent(input, 'change'); + expect(receivedValue).toBe('updated'); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Return-Value Contract + *******************************/ + + describe('events DSL — return-value contract', () => { + it('calls stopPropagation when the handler returns false', async () => { + const inner = vi.fn(() => false); + const outer = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click .btn': inner }, + }); + fixture.renderRoot.innerHTML = ''; + document.addEventListener('click', outer); + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(inner).toHaveBeenCalledTimes(1); + expect(outer).not.toHaveBeenCalled(); + } + finally { + document.removeEventListener('click', outer); + fixture.cleanup(); + } + }); + + it('calls preventDefault when the handler returns the string "cancel"', async () => { + const handler = vi.fn(() => 'cancel'); + const fixture = await mountTemplate({ + target, + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const btn = fixture.renderRoot.querySelector('.btn'); + const ev = new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + }); + btn.dispatchEvent(ev); + expect(handler).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + + it('does neither when the handler returns undefined', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + try { + const btn = fixture.renderRoot.querySelector('.btn'); + const ev = new MouseEvent('click', { + bubbles: true, + composed: true, + cancelable: true, + }); + btn.dispatchEvent(ev); + expect(handler).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(false); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + attachEvent + *******************************/ + + describe('attachEvent — dynamic event binding with auto-cleanup', () => { + it('binds an event to an external selector from inside the component', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ target }); + const externalTarget = document.createElement('div'); + externalTarget.id = 'external-target'; + document.body.appendChild(externalTarget); + try { + fixture.template.attachEvent(externalTarget, 'click', handler); + clickOn(externalTarget); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + externalTarget.remove(); + fixture.cleanup(); + } + }); + + it('returns a handler object that can be aborted manually', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ target }); + const externalTarget = document.createElement('div'); + document.body.appendChild(externalTarget); + try { + const eventHandler = fixture.template.attachEvent(externalTarget, 'click', handler); + expect(eventHandler).toBeDefined(); + expect(typeof eventHandler.abort).toBe('function'); + + eventHandler.abort(); + clickOn(externalTarget); + expect(handler).not.toHaveBeenCalled(); + } + finally { + externalTarget.remove(); + fixture.cleanup(); + } + }); + + it('removes the listener when the component is destroyed', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ target }); + const externalTarget = document.createElement('div'); + document.body.appendChild(externalTarget); + fixture.template.attachEvent(externalTarget, 'click', handler); + fixture.cleanup(); + clickOn(externalTarget); + expect(handler).not.toHaveBeenCalled(); + externalTarget.remove(); + }); + + it('forwards listener options (passive, capture, once) to addEventListener', async () => { + // Production at inpage-menu.js:269 passes `{ passive: true }` directly. + // The 4th arg should accept the natural addEventListener shape. + const fixture = await mountTemplate({ target }); + const externalTarget = document.createElement('div'); + document.body.appendChild(externalTarget); + const spy = vi.spyOn(externalTarget, 'addEventListener'); + try { + fixture.template.attachEvent(externalTarget, 'touchmove', () => {}, { passive: true }); + const call = spy.mock.calls.find(([name]) => name === 'touchmove'); + expect(call).toBeDefined(); + expect(call[2]).toMatchObject({ passive: true }); + } + finally { + spy.mockRestore(); + externalTarget.remove(); + fixture.cleanup(); + } + }); + + it('fires only once when called with { once: true }', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ target }); + const externalTarget = document.createElement('div'); + document.body.appendChild(externalTarget); + try { + fixture.template.attachEvent(externalTarget, 'click', handler, { once: true }); + clickOn(externalTarget); + clickOn(externalTarget); + clickOn(externalTarget); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + externalTarget.remove(); + fixture.cleanup(); + } + }); + }); + + /******************************* + dispatchEvent + *******************************/ + + describe('dispatchEvent — emitting custom events from a component', () => { + it('fires a CustomEvent on the component element with detail equal to the supplied data', async () => { + const fixture = await mountTemplate({ target }); + const handler = vi.fn(); + fixture.host.addEventListener('itemactive', handler); + try { + fixture.template.dispatchEvent('itemactive', { id: 42 }); + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event).toBeInstanceOf(CustomEvent); + expect(event.detail).toEqual({ id: 42 }); + } + finally { + fixture.cleanup(); + } + }); + + it('emits CustomEvents that cross shadow boundaries (composed: true) by default', async () => { + const fixture = await mountTemplate({ target }); + const docHandler = vi.fn(); + document.addEventListener('itemactive', docHandler); + try { + fixture.template.dispatchEvent('itemactive', { id: 1 }); + expect(docHandler).toHaveBeenCalledTimes(1); + } + finally { + document.removeEventListener('itemactive', docHandler); + fixture.cleanup(); + } + }); + + it('invokes the matching on{Name} setting callback before dispatching the DOM event when triggerCallback is true', async () => { + const fixture = await mountTemplate({ target }); + const events = []; + fixture.host.onFoo = function(data) { + events.push(['callback', data]); + }; + fixture.host.addEventListener('foo', function(e) { + events.push(['dom', e.detail]); + }); + try { + fixture.template.dispatchEvent('foo', { x: 1 }); + expect(events).toEqual([ + ['callback', { x: 1 }], + ['dom', { x: 1 }], + ]); + } + finally { + fixture.cleanup(); + } + }); + + it('skips the on{Name} callback when triggerCallback is false', async () => { + const fixture = await mountTemplate({ target }); + const cb = vi.fn(); + const dom = vi.fn(); + fixture.host.onFoo = cb; + fixture.host.addEventListener('foo', dom); + try { + fixture.template.dispatchEvent('foo', { x: 1 }, undefined, { triggerCallback: false }); + expect(cb).not.toHaveBeenCalled(); + expect(dom).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('lets callers override CustomEvent options via the third argument', async () => { + const fixture = await mountTemplate({ target }); + const handler = vi.fn(); + fixture.host.addEventListener('cancelable', handler); + try { + fixture.template.dispatchEvent('cancelable', { x: 1 }, { bubbles: false }); + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0]; + expect(event.bubbles).toBe(false); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Lifecycle Teardown + *******************************/ + + describe('events DSL — lifecycle teardown', () => { + it('removes every events-DSL listener when the template is destroyed', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + const btn = fixture.renderRoot.querySelector('.btn'); + fixture.cleanup(); + clickOn(btn); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not double-bind listeners when attachEvents is called again', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target, + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + try { + fixture.template.attachEvents(); + const btn = fixture.renderRoot.querySelector('.btn'); + clickOn(btn); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); + }); +}); + +/******************************* + Shadow-only — boundary tests +*******************************/ + +describe('shadow only', () => { + /******************************* + Default-Mode Encapsulation + *******************************/ + + describe('events DSL — default-mode encapsulation (shadow boundaries)', () => { + it('does NOT fire on slotted content matching the selector (encapsulation by default)', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target: 'shadow', + events: { 'click .item': handler }, + }); + // Range markers around the rendered content activate the + // isNodeInTemplate filter; the renderer sets these in production. + const startMarker = document.createComment('start'); + const endMarker = document.createComment('end'); + fixture.renderRoot.innerHTML = ''; + fixture.renderRoot.appendChild(startMarker); + const ownTemplate = document.createElement('div'); + ownTemplate.className = 'own-template'; + ownTemplate.innerHTML = ''; + fixture.renderRoot.appendChild(ownTemplate); + fixture.renderRoot.appendChild(endMarker); + fixture.template.startNode = startMarker; + fixture.template.endNode = endMarker; + fixture.host.innerHTML = ''; + try { + // The slotted button lives in light DOM, outside the marker range, so + // isNodeInTemplate rejects it and the handler does not fire. + const slotted = fixture.host.querySelector('.item'); + clickOn(slotted); + expect(handler).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('does NOT fire on elements inside a nested child shadow DOM matching the selector', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target: 'shadow', + events: { 'click .item': handler }, + }); + const startMarker = document.createComment('start'); + const endMarker = document.createComment('end'); + fixture.renderRoot.innerHTML = ''; + fixture.renderRoot.appendChild(startMarker); + const ownTemplate = document.createElement('div'); + ownTemplate.className = 'own-template'; + const childHost = document.createElement('div'); + childHost.className = 'child-host'; + ownTemplate.appendChild(childHost); + fixture.renderRoot.appendChild(ownTemplate); + fixture.renderRoot.appendChild(endMarker); + fixture.template.startNode = startMarker; + fixture.template.endNode = endMarker; + const childShadow = childHost.attachShadow({ mode: 'open' }); + childShadow.innerHTML = ''; + try { + const nested = childShadow.querySelector('.item'); + clickOn(nested); + // composed: true bubbles to the parent shadow, but event.target retargets + // to child-host (which doesn't match .item), so the isDeep check rejects. + expect(handler).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Top-level encapsulation + *******************************/ + + describe('events DSL — projection vs piercing', () => { + // Slotted children are projected through the host's and belong to + // the host's logical template. Default-mode selectors match them without + // ceremony — that's projection, not piercing. The `deep` keyword is for + // a different boundary (another component's shadow tree). + it('DOES fire default handlers on slotted content matching the selector', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target: 'shadow', + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = '
    '; + fixture.host.innerHTML = ''; + try { + clickOn(fixture.host.querySelector('.btn')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('fires component-wide handlers (no selector) for events on the host itself', async () => { + // The host's own surface — its padding, border, or the host element + // dispatched event — is part of "the component" semantically. Binding + // a no-selector handler at the renderRoot misses these because the + // event never enters the shadow tree's bubble path. + const handler = vi.fn(); + const fixture = await mountTemplate({ + target: 'shadow', + events: { 'click': handler }, + }); + try { + clickOn(fixture.host); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('DOES fire default handlers on shadow-internal elements matching the selector', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target: 'shadow', + events: { 'click .btn': handler }, + }); + fixture.renderRoot.innerHTML = ''; + try { + clickOn(fixture.renderRoot.querySelector('.btn')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Deep Keyword (boundary) + *******************************/ + + describe('events DSL — deep keyword (boundary escape, shadow only)', () => { + it('fires on slotted content matching the selector', async () => { + const handler = vi.fn(); + const fixture = await mountTemplate({ + target: 'shadow', + events: { 'deep click .item': handler }, + }); + // Mirrors the default-mode encapsulation setup so this test exercises + // the escape contract under identical scaffolding. + const startMarker = document.createComment('start'); + const endMarker = document.createComment('end'); + fixture.renderRoot.innerHTML = ''; + fixture.renderRoot.appendChild(startMarker); + const ownTemplate = document.createElement('div'); + ownTemplate.className = 'own-template'; + ownTemplate.innerHTML = ''; + fixture.renderRoot.appendChild(ownTemplate); + fixture.renderRoot.appendChild(endMarker); + fixture.template.startNode = startMarker; + fixture.template.endNode = endMarker; + fixture.host.innerHTML = ''; + try { + clickOn(fixture.host.querySelector('.item')); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); +}); diff --git a/packages/templating/test/browser/lifecycle.test.js b/packages/templating/test/browser/lifecycle.test.js new file mode 100644 index 000000000..560c4d1c4 --- /dev/null +++ b/packages/templating/test/browser/lifecycle.test.js @@ -0,0 +1,868 @@ +// Template's own lifecycle contracts — internal wrappers, registry, +// promises, and theme observer. Exercises Template directly without +// going through WebComponentBase or defineComponent. Lifecycle hook +// firing is renderRoot-agnostic, so light DOM is sufficient throughout. + +import { Reaction } from '@semantic-ui/reactivity'; +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template } from '@semantic-ui/templating'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +async function mountTemplate({ template = '
    ', ...opts } = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + const tpl = new Template({ + template, + renderingEngine: realEngine, + element: host, + ...opts, + }); + tpl.initialize(); + await tpl.attach(host); + return { + host, + template: tpl, + cleanup: () => { + if (tpl.initialized && !tpl.destroyed) { + tpl.onDestroyed(); + } + host.remove(); + }, + }; +} + +function fireCustomEvent(element, eventName, detail = {}) { + element.dispatchEvent( + new CustomEvent(eventName, { + bubbles: true, + composed: true, + cancelable: true, + detail, + }), + ); +} + +function snapshotRegistry() { + const names = [...Template.renderedTemplates.keys()]; + const counts = {}; + for (const name of names) { + counts[name] = Template.renderedTemplates.get(name).length; + } + return { + size: names.length, + names, + counts, + totalInstances: Object.values(counts).reduce((s, n) => s + n, 0), + }; +} + +describe('Template — lifecycle', () => { + /******************************* + Hook firing order + *******************************/ + + describe('hook firing order', () => { + it('runs createComponent then instance.initialize then onCreated during initialize()', () => { + const calls = []; + const createComponent = vi.fn(() => { + calls.push('createComponent'); + return { + initialize() { + calls.push('instance.initialize'); + }, + }; + }); + const onCreated = vi.fn(() => calls.push('onCreated')); + + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + createComponent, + onCreated, + }); + template.initialize(); + expect(calls).toEqual(['createComponent', 'instance.initialize', 'onCreated']); + expect(createComponent).toHaveBeenCalledTimes(1); + expect(onCreated).toHaveBeenCalledTimes(1); + }); + + it('does not fire onRendered during initialize()', () => { + const onCreated = vi.fn(); + const onRendered = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onCreated, + onRendered, + }); + template.initialize(); + expect(onCreated).toHaveBeenCalledTimes(1); + expect(onRendered).not.toHaveBeenCalled(); + }); + + it('fires onRendered after a render() call', async () => { + const onCreated = vi.fn(); + const onRendered = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onCreated, + onRendered, + }); + template.initialize(); + template.render(); + // render() schedules onRendered via setTimeout(fn, 0) + await new Promise(r => setTimeout(r, 5)); + expect(onCreated).toHaveBeenCalledTimes(1); + expect(onRendered).toHaveBeenCalledTimes(1); + }); + + it('fires onDestroyed when the wrapper is invoked', () => { + const onCreated = vi.fn(); + const onDestroyed = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onCreated, + onDestroyed, + }); + template.initialize(); + template.onDestroyed(); + expect(onDestroyed).toHaveBeenCalledTimes(1); + expect(template.destroyed).toBe(true); + }); + }); + + /******************************* + Hook callback signatures + *******************************/ + + describe('callback params', () => { + it('passes destructurable params to onCreated', () => { + let received; + const onCreated = vi.fn((params) => { + received = params; + }); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onCreated, + }); + template.initialize(); + expect(received).toBeDefined(); + expect(received).toHaveProperty('self'); + expect(received).toHaveProperty('tpl'); + expect(received).toHaveProperty('component'); + expect(received).toHaveProperty('state'); + expect(received).toHaveProperty('data'); + expect(received).toHaveProperty('isClient'); + expect(received).toHaveProperty('isServer'); + expect(received).toHaveProperty('isHydrating'); + expect(received).toHaveProperty('reaction'); + expect(received).toHaveProperty('signal'); + expect(received).toHaveProperty('interval'); + expect(received).toHaveProperty('timeout'); + }); + + it('passes the same callParams object to onDestroyed', () => { + let createdParams; + let destroyedParams; + const onCreated = vi.fn((p) => { + createdParams = p; + }); + const onDestroyed = vi.fn((p) => { + destroyedParams = p; + }); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onCreated, + onDestroyed, + }); + template.initialize(); + template.onDestroyed(); + expect(destroyedParams).toBe(createdParams); + }); + }); + + /******************************* + onUpdated + *******************************/ + + describe('onUpdated wrapper', () => { + it('does not invoke onUpdated wrapper directly during initialize()', () => { + const onUpdated = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onUpdated, + }); + template.initialize(); + expect(typeof template.onUpdated).toBe('function'); + }); + + it('invokes the user onUpdated callback when the state Reaction fires after first render', async () => { + const onUpdated = vi.fn(); + const fixture = await mountTemplate({ + template: '', + defaultState: { count: 0 }, + onUpdated, + }); + try { + fixture.template.markRendered(); + + fixture.template.state.count.set(1); + Reaction.flush(); + await Promise.resolve(); + await Promise.resolve(); + + expect(onUpdated).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('emits a single updated DOM event when the state Reaction fires', async () => { + const fixture = await mountTemplate({ + template: '', + defaultState: { count: 0 }, + }); + const heard = vi.fn(); + fixture.host.addEventListener('updated', heard); + try { + fixture.template.markRendered(); + fixture.template.state.count.set(1); + Reaction.flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('coalesces synchronous state mutations into one updated DOM event', async () => { + const fixture = await mountTemplate({ + template: '', + defaultState: { a: 0, b: 0, c: 0 }, + }); + const heard = vi.fn(); + fixture.host.addEventListener('updated', heard); + try { + fixture.template.markRendered(); + fixture.template.state.a.set(1); + fixture.template.state.b.set(2); + fixture.template.state.c.set(3); + Reaction.flush(); + await Promise.resolve(); + await Promise.resolve(); + expect(heard).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + + it('does not dispatch updated before markRendered() (no first-render fire)', async () => { + const fixture = await mountTemplate({ + template: '', + defaultState: { count: 0 }, + }); + const heard = vi.fn(); + fixture.host.addEventListener('updated', heard); + try { + fixture.template.state.count.set(1); + Reaction.flush(); + await Promise.resolve(); + await Promise.resolve(); + // state reaction only schedules when this.rendered === true + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('flips updateScheduled true while pending and false after the microtask fires', async () => { + const fixture = await mountTemplate({ + template: '', + defaultState: { count: 0 }, + }); + try { + fixture.template.markRendered(); + fixture.template.state.count.set(1); + Reaction.flush(); + expect(fixture.template.updateScheduled).toBe(true); + expect(fixture.host.updateScheduled).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(fixture.template.updateScheduled).toBe(false); + expect(fixture.host.updateScheduled).toBe(false); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Hydration DOM-event gating + *******************************/ + + describe('isHydrating gates DOM events', () => { + it('runs the onCreated user callback during hydration', async () => { + const onCreated = vi.fn(); + const fixture = await mountTemplate({ + template: '', + onCreated, + }); + try { + const heard = vi.fn(); + fixture.host.addEventListener('created', heard); + + fixture.template.isHydrating = true; + fixture.template.onCreated(); + // once during mount + this manual call + expect(onCreated).toHaveBeenCalledTimes(2); + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('suppresses the rendered DOM event during hydration but still runs the user callback', async () => { + const onRendered = vi.fn(); + const fixture = await mountTemplate({ + template: '', + onRendered, + }); + const heard = vi.fn(); + fixture.host.addEventListener('rendered', heard); + try { + fixture.template.isHydrating = true; + fixture.template.onRendered(); + expect(onRendered).toHaveBeenCalledTimes(1); + expect(heard).not.toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('dispatches the created DOM event when not hydrating', async () => { + const fixture = await mountTemplate({ + template: '', + }); + const heard = vi.fn(); + fixture.host.addEventListener('created', heard); + try { + fixture.template.isHydrating = false; + fixture.template.onCreated(); + expect(heard).toHaveBeenCalledTimes(1); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + lifecyclePromise + *******************************/ + + describe('lifecyclePromise', () => { + it('returns the same Promise for repeated calls before resolution', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + const p1 = template.lifecyclePromise('rendered'); + const p2 = template.lifecyclePromise('rendered'); + expect(p1).toBe(p2); + }); + + it('caches the resolved promise for one-shot events (created)', async () => { + const fixture = await mountTemplate({ template: '' }); + try { + // pre-access then resolve + fixture.template.lifecyclePromise('created'); + fixture.template.resolveLifecyclePromise('created'); + const p1 = fixture.template.lifecyclePromise('created'); + const p2 = fixture.template.lifecyclePromise('created'); + expect(p1).toBe(p2); + await expect(Promise.race([p1, new Promise((_, rej) => setTimeout(() => rej(new Error('hung')), 50))])) + .resolves.toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('returns a fresh Promise after resolution for the recurring event (updated)', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + const p1 = template.lifecyclePromise('updated'); + template.resolveLifecyclePromise('updated'); + const p2 = template.lifecyclePromise('updated'); + // recurring: cached promise was deleted, p2 is fresh + expect(p1).not.toBe(p2); + }); + + it('caches a resolved promise on first fire so late awaiters do not hang', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + template.resolveLifecyclePromise('rendered'); + // No prior awaiter — but a resolved promise is now cached for late access + expect(template.lifecyclePromises.rendered).toBeInstanceOf(Promise); + }); + + it('resolves a late awaiter accessed AFTER resolveLifecyclePromise has fired', async () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + // simulate the lifecycle event firing without any prior promise access + template.resolveLifecyclePromise('created'); + // consumer awaits el.created for the first time + const promise = template.lifecyclePromise('created'); + const settled = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + expect(settled).toBe('resolved'); + }); + + it('resolves lifecyclePromise(rendered) when onRendered fires during isHydrating', async () => { + const fixture = await mountTemplate({ template: '' }); + try { + const promise = fixture.template.lifecyclePromise('rendered'); + + fixture.template.isHydrating = true; + fixture.template.onRendered(); + + const settled = await Promise.race([ + promise.then(() => 'resolved'), + new Promise(resolve => setTimeout(() => resolve('hung'), 100)), + ]); + expect(settled).toBe('resolved'); + } + finally { + fixture.cleanup(); + } + }); + + it('runs synchronous DOM event listener before lifecyclePromise then-continuation', async () => { + // The lifecycle wrapper resolves the promise (then-callbacks queued as + // microtasks) and THEN dispatches the DOM event (listener runs sync). + // Net order: DOM listener first, then-continuation in the next microtask. + const fixture = await mountTemplate({ template: '' }); + try { + const order = []; + const promise = fixture.template.lifecyclePromise('updated').then(() => { + order.push('promise'); + }); + fixture.host.addEventListener('updated', () => { + order.push('domEvent'); + }); + fixture.template.resolveLifecyclePromise('updated'); + fixture.template.dispatchEvent( + 'updated', + { component: fixture.template.instance }, + { composed: false }, + { triggerCallback: false }, + ); + await promise; + expect(order[0]).toBe('domEvent'); + expect(order[1]).toBe('promise'); + } + finally { + fixture.cleanup(); + } + }); + }); + + /******************************* + Theme observer + *******************************/ + + describe('onThemeChanged observer', () => { + it('does not install a MutationObserver when no onThemeChanged callback is provided', async () => { + const fixture = await mountTemplate({ template: '' }); + try { + expect(fixture.template.observers.length).toBe(0); + } + finally { + fixture.cleanup(); + } + }); + + it('installs a MutationObserver when onThemeChanged is provided', async () => { + const fixture = await mountTemplate({ + template: '', + onThemeChanged: () => {}, + }); + try { + expect(fixture.template.observers.length).toBe(1); + } + finally { + fixture.cleanup(); + } + }); + + it('fires onThemeChanged when the html class attribute changes', async () => { + const onThemeChanged = vi.fn(); + const fixture = await mountTemplate({ + template: '', + onThemeChanged, + }); + try { + const html = document.documentElement; + const previous = html.className; + html.classList.add('dark'); + // 10ms debounce + await new Promise(r => setTimeout(r, 30)); + expect(onThemeChanged).toHaveBeenCalled(); + html.className = previous; + await new Promise(r => setTimeout(r, 30)); + } + finally { + fixture.cleanup(); + } + }); + + it('fires onThemeChanged when a themechange event is dispatched on html', async () => { + const onThemeChanged = vi.fn(); + const fixture = await mountTemplate({ + template: '', + onThemeChanged, + }); + try { + fireCustomEvent(document.documentElement, 'themechange', { theme: 'dark' }); + await new Promise(r => setTimeout(r, 30)); + expect(onThemeChanged).toHaveBeenCalled(); + } + finally { + fixture.cleanup(); + } + }); + + it('coalesces a class mutation and a themechange event into one callback', async () => { + const onThemeChanged = vi.fn(); + const fixture = await mountTemplate({ + template: '', + onThemeChanged, + }); + try { + const html = document.documentElement; + const previous = html.className; + html.classList.add('dark'); + fireCustomEvent(html, 'themechange', { theme: 'dark' }); + await new Promise(r => setTimeout(r, 30)); + expect(onThemeChanged).toHaveBeenCalledTimes(1); + html.className = previous; + await new Promise(r => setTimeout(r, 30)); + } + finally { + fixture.cleanup(); + } + }); + + it('disconnects the MutationObserver on destroy', async () => { + const onThemeChanged = vi.fn(); + const fixture = await mountTemplate({ + template: '', + onThemeChanged, + }); + const observer = fixture.template.observers[0]; + const disconnectSpy = vi.spyOn(observer, 'disconnect'); + fixture.template.onDestroyed(); + fixture.cleanup(); + expect(disconnectSpy).toHaveBeenCalled(); + }); + }); + + /******************************* + renderedTemplates registry + *******************************/ + + describe('renderedTemplates registry', () => { + it('adds a template on onCreated and removes it on onDestroyed', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'registry-add-remove', + }); + template.initialize(); + const after = snapshotRegistry(); + expect(after.counts['registry-add-remove']).toBe(1); + template.onDestroyed(); + const afterDestroy = snapshotRegistry(); + expect(afterDestroy.counts['registry-add-remove'] || 0).toBe(0); + }); + + it('does not register isPrototype templates', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'proto-template', + isPrototype: true, + }); + template.initialize(); + const after = snapshotRegistry(); + expect(after.counts['proto-template'] || 0).toBe(0); + }); + + it('grows the array to N when N templates share a name', () => { + const a = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'shared-name', + }); + const b = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'shared-name', + }); + const c = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'shared-name', + }); + a.initialize(); + b.initialize(); + c.initialize(); + expect(snapshotRegistry().counts['shared-name']).toBe(3); + b.onDestroyed(); + expect(snapshotRegistry().counts['shared-name']).toBe(2); + a.onDestroyed(); + c.onDestroyed(); + }); + + it('auto-names anonymous templates using Template.templateCount', () => { + Template.templateCount = 0; + const a = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + const b = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + expect(a.templateName).toBe('Anonymous #1'); + expect(b.templateName).toBe('Anonymous #2'); + }); + + it('removes the template from registry BEFORE invoking the user onDestroyed callback', () => { + const observed = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'registry-order', + onDestroyed() { + observed(snapshotRegistry().counts['registry-order'] || 0); + }, + }); + template.initialize(); + template.onDestroyed(); + expect(observed).toHaveBeenCalledWith(0); + }); + }); + + /******************************* + Server-side behavior + *******************************/ + + describe('server-side behavior', () => { + let originalIsServer; + + beforeEach(() => { + originalIsServer = Template.isServer; + }); + + afterEach(() => { + Template.isServer = originalIsServer; + }); + + it('runs onCreated callback on the server', () => { + Template.isServer = true; + const onCreated = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onCreated, + }); + template.initialize(); + expect(onCreated).toHaveBeenCalledTimes(1); + Template.isServer = originalIsServer; + template.onDestroyed(); + }); + + it('does not dispatch the created DOM event on the server', async () => { + Template.isServer = true; + const fixture = await mountTemplate({ + template: '', + }); + const heard = vi.fn(); + fixture.host.addEventListener('created', heard); + try { + // dispatchEvent early-returns when Template.isServer + fixture.template.onCreated(); + expect(heard).not.toHaveBeenCalled(); + } + finally { + Template.isServer = originalIsServer; + fixture.cleanup(); + } + }); + + it('schedules onRendered via setTimeout regardless of Template.isServer toggle', async () => { + // render() gates on the imported `isServer` from utils (frozen at + // module init), not on Template.isServer. So toggling Template.isServer + // in tests does NOT suppress onRendered scheduling. The actual + // server-side path (Node) sets utils.isServer=true at import time. + Template.isServer = true; + const onRendered = vi.fn(); + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + onRendered, + }); + template.initialize(); + template.render(); + await new Promise(r => setTimeout(r, 5)); + expect(onRendered).toHaveBeenCalledTimes(1); + Template.isServer = originalIsServer; + template.onDestroyed(); + }); + + it('does not install the theme MutationObserver on the server', async () => { + Template.isServer = true; + const fixture = await mountTemplate({ + template: '', + onThemeChanged: () => {}, + }); + try { + expect(fixture.template.observers.length).toBe(0); + } + finally { + Template.isServer = originalIsServer; + fixture.cleanup(); + } + }); + }); + + /******************************* + Destroy cleanup + *******************************/ + + describe('destroy cleanup', () => { + it('aborts the abortController signal', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + expect(template.abortSignal.aborted).toBe(false); + template.onDestroyed(); + expect(template.abortSignal.aborted).toBe(true); + }); + + it('stops registered reactions and clears the array entries', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + let runs = 0; + template.reaction(() => { + runs++; + }); + Reaction.flush(); + expect(runs).toBe(1); + const reactions = template.reactions; + template.onDestroyed(); + reactions.forEach(r => { + expect(r._stopped !== undefined ? r._stopped : true).toBeTruthy(); + }); + }); + + it('flips destroyed=true and rendered=false on destroy', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + template.initialize(); + template.markRendered(); + expect(template.rendered).toBe(true); + template.onDestroyed(); + expect(template.destroyed).toBe(true); + expect(template.rendered).toBe(false); + }); + + it('detaches from parentTemplate._childTemplates on destroy', () => { + const parent = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + const child = new Template({ + template: '
    ', + renderingEngine: realEngine, + }); + parent.initialize(); + child.initialize(); + child.setParent(parent); + expect(parent._childTemplates.length).toBe(1); + child.onDestroyed(); + expect(parent._childTemplates.length).toBe(0); + parent.onDestroyed(); + }); + + it('aborts the eventController on destroy (cascades from abortSignal)', async () => { + const fixture = await mountTemplate({ + template: '', + events: { 'click span'() {} }, + }); + try { + const ec = fixture.template.eventController; + expect(ec).toBeDefined(); + expect(ec.signal.aborted).toBe(false); + fixture.template.onDestroyed(); + expect(ec.signal.aborted).toBe(true); + } + finally { + fixture.cleanup(); + } + }); + + it('leaves the registry empty after a clean lifecycle', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + templateName: 'leak-check', + }); + template.initialize(); + template.onDestroyed(); + const snap = snapshotRegistry(); + expect(snap.totalInstances).toBe(0); + }); + }); +}); diff --git a/packages/templating/test/browser/subtemplate-composition.test.js b/packages/templating/test/browser/subtemplate-composition.test.js new file mode 100644 index 000000000..eeedc3138 --- /dev/null +++ b/packages/templating/test/browser/subtemplate-composition.test.js @@ -0,0 +1,295 @@ +// Subtemplate composition with real DOM element shapes. The bulk of +// subtemplate-settings semantics is covered in the node test file +// (test/subtemplate-settings.test.js); this file pins cases that need a real +// Element on the parent — primarily the parent-fallback path where +// `parentTemplate.element?.settings` is reached through `this.element` after +// the renderer's setElement(parent.element) wiring. +// +// Subtemplates render into the parent's region rather than their own root, so +// light DOM is the right setup — the parent owns the element and the child +// shares it. + +import { Reaction } from '@semantic-ui/reactivity'; +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Template } from '../../src/template.js'; + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +// Mounts a parent Template attached to a real host element (light DOM) plus a +// child subtemplate that shares the parent's element via the same +// setElement(parent.element) wiring the renderer performs in production. +async function mountSubtemplatePair({ + parentTemplate = '
    ', + childTemplate = '', + parentSettings = {}, + childDefaultSettings, + childData = {}, +} = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + + // Settings must land on the host before child.initialize() so the + // subtemplate Proxy captures the reference. + host.settings = parentSettings; + + const parent = new Template({ + template: parentTemplate, + renderingEngine: realEngine, + element: host, + }); + parent.initialize(); + await parent.attach(host); + + const child = new Template({ + template: childTemplate, + renderingEngine: realEngine, + defaultSettings: childDefaultSettings, + data: childData, + element: host, + }); + child.setParent(parent); + child.initialize(); + + return { + host, + parent, + child, + cleanup: () => { + try { + if (child.initialized && !child.destroyed) { + child.onDestroyed(); + } + } + catch (_) {} + try { + if (parent.initialized && !parent.destroyed) { + parent.onDestroyed(); + } + } + catch (_) {} + host.remove(); + }, + }; +} + +// Mounts only a parent Template + host (no child created). Used by tests that +// walk the clone() flow manually. +async function mountParent({ + parentTemplate = '
    ', + parentSettings = {}, +} = {}) { + const host = document.createElement('div'); + document.body.appendChild(host); + host.settings = parentSettings; + + const parent = new Template({ + template: parentTemplate, + renderingEngine: realEngine, + element: host, + }); + parent.initialize(); + await parent.attach(host); + + return { + host, + parent, + cleanup: () => { + try { + if (parent.initialized && !parent.destroyed) { + parent.onDestroyed(); + } + } + catch (_) {} + host.remove(); + }, + }; +} + +/******************************* + Parent fallback +*******************************/ + +describe('Subtemplate settings — parent fallback through real element', () => { + it('reads own settings (no fallback) when key is in defaultSettings', async () => { + const fixture = await mountSubtemplatePair({ + childDefaultSettings: { ownProp: 'X' }, + parentSettings: { brand: 'parent-brand' }, + }); + try { + expect(fixture.child.settings.ownProp).toBe('X'); + } + finally { + fixture.cleanup(); + } + }); + + it('falls back to parent host element.settings for keys not in defaultSettings', async () => { + const fixture = await mountSubtemplatePair({ + childDefaultSettings: { ownProp: 'X' }, + parentSettings: { brand: 'parent-brand' }, + }); + try { + expect(fixture.child.settings.ownProp).toBe('X'); + expect(fixture.child.settings.brand).toBe('parent-brand'); + expect(fixture.child.settings.notInEither).toBeUndefined(); + } + finally { + fixture.cleanup(); + } + }); + + it('shadows the parent fallback once an unrelated key is written through the Proxy', async () => { + const fixture = await mountSubtemplatePair({ + childDefaultSettings: { ownProp: 'X' }, + parentSettings: { brand: 'parent-brand' }, + }); + try { + expect(fixture.child.settings.brand).toBe('parent-brand'); + fixture.child.settings.brand = 'shadowed'; + expect(fixture.child.settings.brand).toBe('shadowed'); + expect(fixture.host.settings.brand).toBe('parent-brand'); + } + finally { + fixture.cleanup(); + } + }); +}); + +/******************************* + Reactivity +*******************************/ + +describe('Subtemplate settings — reactivity in browser context', () => { + it('a Reaction reading through the Proxy fires when updateSubtemplateSettings updates the same key', async () => { + const fixture = await mountSubtemplatePair({ + childDefaultSettings: { theme: 'light' }, + }); + let runs = 0; + let observed; + let reaction; + try { + reaction = Reaction.create(() => { + runs++; + observed = fixture.child.settings.theme; + }); + expect(runs).toBe(1); + expect(observed).toBe('light'); + + fixture.child.updateSubtemplateSettings({ theme: 'dark' }); + Reaction.flush(); + expect(runs).toBe(2); + expect(observed).toBe('dark'); + } + finally { + reaction?.stop(); + fixture.cleanup(); + } + }); +}); + +/******************************* + Production wiring +*******************************/ + +describe('Subtemplate composition — clone + wire + initialize sequence', () => { + it('clone → setElement → setParent → initialize completes and settings work end-to-end', async () => { + // Mount the parent only; carry out the clone sequence for the child + // manually to mirror the renderer's behavior in production. + const parentFixture = await mountParent({ + parentTemplate: '
    ', + parentSettings: { brand: 'parent' }, + }); + + const proto = new Template({ + template: '{theme}', + templateName: 'protoChild', + defaultSettings: { theme: 'light' }, + renderingEngine: realEngine, + }); + + let child; + try { + child = proto.clone({ + parentTemplate: parentFixture.parent, + data: { theme: 'dark' }, + }); + child.setElement(parentFixture.host); + child.setParent(parentFixture.parent); + child.initialize(); + + expect(child.isSubtemplate()).toBe(true); + expect(child.settings.theme).toBe('dark'); + expect(child.settings.brand).toBe('parent'); + expect(parentFixture.parent._childTemplates).toContain(child); + } + finally { + try { + child?.onDestroyed(); + } + catch (_) {} + parentFixture.cleanup(); + } + }); + + it('two independently-cloned children of the same prototype have fresh state and settings signals', async () => { + const parentFixture = await mountParent({ + parentTemplate: '
    ', + parentSettings: {}, + }); + + const proto = new Template({ + template: '{theme}', + defaultSettings: { theme: 'light' }, + defaultState: { count: 0 }, + renderingEngine: realEngine, + }); + + let childA, childB; + try { + childA = proto.clone({ + parentTemplate: parentFixture.parent, + data: { theme: 'red' }, + }); + childA.setElement(parentFixture.host); + childA.setParent(parentFixture.parent); + childA.initialize(); + + childB = proto.clone({ + parentTemplate: parentFixture.parent, + data: { theme: 'blue' }, + }); + childB.setElement(parentFixture.host); + childB.setParent(parentFixture.parent); + childB.initialize(); + + expect(childA.settings.theme).toBe('red'); + expect(childB.settings.theme).toBe('blue'); + + expect(childA.settingsVars).not.toBe(childB.settingsVars); + + expect(childA.state.count).not.toBe(childB.state.count); + + expect(parentFixture.parent._childTemplates).toContain(childA); + expect(parentFixture.parent._childTemplates).toContain(childB); + } + finally { + try { + childA?.onDestroyed(); + } + catch (_) {} + try { + childB?.onDestroyed(); + } + catch (_) {} + parentFixture.cleanup(); + } + }); +}); diff --git a/packages/templating/test/browser/tree-traversal-dom.test.js b/packages/templating/test/browser/tree-traversal-dom.test.js new file mode 100644 index 000000000..aa9ad31c9 --- /dev/null +++ b/packages/templating/test/browser/tree-traversal-dom.test.js @@ -0,0 +1,606 @@ +// Tree traversal — DOM cascade. +// +// DOM-cascade tests need elements with `.component` set — the wiring real +// web components do via WebComponentBase. We sidestep the component package +// by manually assigning `.component` and `.dataContext` on host elements. +// Light DOM (regular parent/child) is enough for findParent (walks up via +// .parentNode || .host). findChild paths still require a shadowRoot on the +// parent because that's what the source gates on; tests that require it +// build a shadow root inline. + +import { afterEach, describe, expect, it } from 'vitest'; + +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template } from '@semantic-ui/templating'; + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; + document.body.innerHTML = ''; +}); + +/* Helper: build a parent → child light-DOM hierarchy with the + .component / .dataContext refs that findParent walks. */ +function buildDomCascade({ + parentTemplateName = 'uiPanels', + childTemplateName = 'uiPanel', + parentTagName = 'ui-panels', + childTagName = 'ui-panel', + parentInstance = {}, + parentState = {}, + parentData = {}, + childInstance = {}, +} = {}) { + const parentHost = document.createElement(parentTagName); + const childHost = document.createElement(childTagName); + document.body.appendChild(parentHost); + parentHost.appendChild(childHost); + + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: parentTemplateName, + element: parentHost, + createComponent: () => parentInstance, + defaultState: parentState, + data: parentData, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: childTemplateName, + element: childHost, + createComponent: () => childInstance, + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + return { + parentHost, + childHost, + parentTpl, + childTpl, + cleanup() { + try { + childTpl.onDestroyed(); + } + catch (_) {} + try { + parentTpl.onDestroyed(); + } + catch (_) {} + parentHost.remove(); + }, + }; +} + +/******************************* + findParent — DOM +*******************************/ + +describe('findParent — DOM cascade (light DOM, parent.component wired)', () => { + it('child component finds parent by camelCase templateName', () => { + const fixture = buildDomCascade({ + parentInstance: { + panels: [{ id: 'a' }, { id: 'b' }], + isHidden(index) { + return false; + }, + }, + }); + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + expect(typeof found.isHidden).toBe('function'); + expect(found.isHidden(0)).toBe(false); + expect(found.panels).toEqual([{ id: 'a' }, { id: 'b' }]); + }); + + it('walks across shadow boundaries via element.host', () => { + // Real-world nesting: outer host -> outer shadow -> middle host -> + // middle shadow -> inner host. The DOM cascade traverses each shadow + // boundary via `.host` so the inner template can reach the outer. + const outerHost = document.createElement('ui-outer'); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + document.body.appendChild(outerHost); + + const middleHost = document.createElement('ui-middle'); + const middleShadow = middleHost.attachShadow({ mode: 'open' }); + outerShadow.appendChild(middleHost); + + const innerHost = document.createElement('ui-inner'); + middleShadow.appendChild(innerHost); + + const outerTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'outer', + element: outerHost, + createComponent: () => ({ depth: 0 }), + }); + outerTpl.initialize(); + outerHost.component = outerTpl.instance; + outerHost.dataContext = outerTpl.getDataContext(); + + const middleTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'middle', + element: middleHost, + createComponent: () => ({ depth: 1 }), + }); + middleTpl.initialize(); + middleHost.component = middleTpl.instance; + middleHost.dataContext = middleTpl.getDataContext(); + + const innerTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'inner', + element: innerHost, + createComponent: () => ({ depth: 2 }), + }); + innerTpl.initialize(); + innerHost.component = innerTpl.instance; + innerHost.dataContext = innerTpl.getDataContext(); + + const found = innerTpl.findParent('outer'); + expect(found).toBeDefined(); + expect(found.depth).toBe(0); + }); + + it('returns undefined when no ancestor matches the name', () => { + const fixture = buildDomCascade(); + expect(fixture.childTpl.findParent('totallyDifferent')).toBeUndefined(); + }); + + it('with no name argument, returns the first ancestor with a templateName', () => { + const fixture = buildDomCascade(); + const found = fixture.childTpl.findParent(); + expect(found).toBeDefined(); + expect(found.templateName).toBe('uiPanels'); + }); + + describe('exposes everything the parent is aware of', () => { + it('exposes state Signals through findParent', () => { + // CodePlaygroundPreview's pattern: findParent(...).someStateSignal.get() + const fixture = buildDomCascade({ + parentState: { count: 5 }, + parentInstance: { + publicApi() { + return 'ok'; + }, + }, + }); + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + expect(typeof found.publicApi).toBe('function'); + expect(typeof found.count?.get).toBe('function'); + expect(found.count.get()).toBe(5); + }); + + it('exposes data closure through findParent', () => { + const fixture = buildDomCascade({ + parentData: { secret: 'closure-snapshot' }, + parentInstance: { + publicApi() { + return 'ok'; + }, + }, + }); + const found = fixture.childTpl.findParent('uiPanels'); + expect(typeof found.publicApi).toBe('function'); + expect(found.secret).toBe('closure-snapshot'); + }); + + it('DOM cascade exposes the parentNode.dataContext alongside instance methods', () => { + const parentInstance = { + hello() { + return 'world'; + }, + magic: 1, + }; + + const dom = buildDomCascade({ + parentTemplateName: 'sharedShape', + childTemplateName: 'kid', + parentInstance, + parentState: { count: 0 }, + parentData: { secret: 'snapshot' }, + }); + + const fromDom = dom.childTpl.findParent('sharedShape'); + expect(typeof fromDom.count?.get).toBe('function'); + expect(fromDom.secret).toBe('snapshot'); + expect(typeof fromDom.hello).toBe('function'); + expect(fromDom.magic).toBe(1); + }); + }); + + describe('forgiving lookup — kebab and camel forms', () => { + it('findParent("uiPanels") works (camel form)', () => { + const fixture = buildDomCascade({ + parentInstance: { ok: true }, + }); + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + expect(found.ok).toBe(true); + }); + + it('findParent("ui-panels") works (kebab form)', () => { + const fixture = buildDomCascade({ + parentInstance: { ok: true }, + }); + const found = fixture.childTpl.findParent('ui-panels'); + expect(found).toBeDefined(); + expect(found.ok).toBe(true); + }); + + it('kebab and camel inputs converge on the same Template', () => { + const fixture = buildDomCascade({ + parentInstance: { ok: true, sentinel: Math.random() }, + }); + const camel = fixture.childTpl.findParent('uiPanels'); + const kebab = fixture.childTpl.findParent('ui-panels'); + expect(camel).toBeDefined(); + expect(kebab).toBeDefined(); + expect(camel.sentinel).toBe(kebab.sentinel); + }); + }); +}); + +/******************************* + findChild / findChildren — DOM +*******************************/ + +// findChild's DOM-cascade branch is gated on `template.element?.shadowRoot`, +// so these tests build a shadow root on the parent host and place children +// inside it. Light DOM is not enough. + +describe('findChild / findChildren — DOM cascade', () => { + it('finds direct DOM child by templateName', () => { + const parentHost = document.createElement('ui-panels'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'uiPanels', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childHost = document.createElement('ui-panel'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'uiPanel', + element: childHost, + createComponent: () => ({ theId: 'kid' }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + const found = parentTpl.findChild('uiPanel'); + expect(found).toBeDefined(); + expect(found.theId).toBe('kid'); + }); + + it('findChildren returns all matching DOM children', () => { + const parentHost = document.createElement('ui-list'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'list', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childTpls = []; + for (let i = 0; i < 3; i++) { + const childHost = document.createElement('ui-row'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'row', + element: childHost, + createComponent: () => ({ idx: i }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + childTpls.push(childTpl); + } + + const found = parentTpl.findChildren('row'); + expect(Array.isArray(found)).toBe(true); + expect(found.length).toBe(3); + expect(found.map(f => f.idx)).toEqual([0, 1, 2]); + }); + + it('recurses into 2-deep nested DOM child shadow roots', () => { + const outerHost = document.createElement('ui-outer'); + const outerShadow = outerHost.attachShadow({ mode: 'open' }); + document.body.appendChild(outerHost); + const outerTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'outer', + element: outerHost, + }); + outerTpl.initialize(); + outerHost.component = outerTpl.instance; + outerHost.dataContext = outerTpl.getDataContext(); + + const middleHost = document.createElement('ui-middle'); + const middleShadow = middleHost.attachShadow({ mode: 'open' }); + outerShadow.appendChild(middleHost); + const middleTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'middle', + element: middleHost, + }); + middleTpl.initialize(); + middleHost.component = middleTpl.instance; + middleHost.dataContext = middleTpl.getDataContext(); + + const innerHost = document.createElement('ui-inner'); + middleShadow.appendChild(innerHost); + const innerTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'inner', + element: innerHost, + createComponent: () => ({ deep: true }), + }); + innerTpl.initialize(); + innerHost.component = innerTpl.instance; + innerHost.dataContext = innerTpl.getDataContext(); + + const found = outerTpl.findChild('inner'); + expect(found).toBeDefined(); + expect(found.deep).toBe(true); + }); + + it('returns empty array when no DOM children match', () => { + const parentHost = document.createElement('ui-panels'); + parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'uiPanels', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const found = parentTpl.findChildren('nothingMatches'); + expect(found).toEqual([]); + }); + + describe('exposes everything the child is aware of', () => { + it('exposes state Signals through findChild', () => { + const parentHost = document.createElement('ui-list'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'list', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childHost = document.createElement('ui-row'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'row', + element: childHost, + defaultState: { count: 7 }, + createComponent: () => ({ + publicApi() { + return 'ok'; + }, + }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + const found = parentTpl.findChild('row'); + expect(found).toBeDefined(); + expect(typeof found.publicApi).toBe('function'); + expect(typeof found.count?.get).toBe('function'); + expect(found.count.get()).toBe(7); + }); + }); + + describe('forgiving lookup — kebab and camel forms', () => { + it('findChild("uiPanel") works (camel form)', () => { + const parentHost = document.createElement('ui-panels'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'uiPanels', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childHost = document.createElement('ui-panel'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'uiPanel', + element: childHost, + createComponent: () => ({ ok: true }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + expect(parentTpl.findChild('uiPanel')).toBeDefined(); + }); + + it('findChild("ui-panel") works (kebab form)', () => { + const parentHost = document.createElement('ui-panels'); + const shadow = parentHost.attachShadow({ mode: 'open' }); + document.body.appendChild(parentHost); + const parentTpl = new Template({ + renderingEngine: realEngine, + template: '', + templateName: 'uiPanels', + element: parentHost, + }); + parentTpl.initialize(); + parentHost.component = parentTpl.instance; + parentHost.dataContext = parentTpl.getDataContext(); + + const childHost = document.createElement('ui-panel'); + shadow.appendChild(childHost); + const childTpl = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'uiPanel', + element: childHost, + createComponent: () => ({ ok: true }), + }); + childTpl.initialize(); + childHost.component = childTpl.instance; + childHost.dataContext = childTpl.getDataContext(); + + const found = parentTpl.findChild('ui-panel'); + expect(found).toBeDefined(); + expect(found.ok).toBe(true); + }); + }); +}); + +/******************************* + Cascade precedence +*******************************/ + +describe('findParent precedence — DOM cascade wins over subtemplate cascade', () => { + it('when both DOM ancestor and subtemplate parent share the templateName, DOM match wins', () => { + const fixture = buildDomCascade({ + parentInstance: { source: 'dom' }, + }); + + // Wire the child to also have a subtemplate parent with the same + // templateName but different identity — so we can tell which won. + const altParent = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'uiPanels', + createComponent: () => ({ source: 'subtemplate' }), + }); + altParent.initialize(); + fixture.childTpl.setParent(altParent); + + const found = fixture.childTpl.findParent('uiPanels'); + expect(found).toBeDefined(); + expect(found.source).toBe('dom'); + }); + + it('falls back to subtemplate cascade when DOM ancestor does not match', () => { + const fixture = buildDomCascade({ + parentTemplateName: 'differentName', + parentInstance: { source: 'dom-no-match' }, + }); + + const subParent = new Template({ + renderingEngine: realEngine, + template: '
    ', + templateName: 'wantedParent', + createComponent: () => ({ source: 'subtemplate-fallback' }), + }); + subParent.initialize(); + fixture.childTpl.setParent(subParent); + + const found = fixture.childTpl.findParent('wantedParent'); + expect(found).toBeDefined(); + expect(found.source).toBe('subtemplate-fallback'); + }); +}); + +/******************************* + Heap / GC guards +*******************************/ + +function totalRegistered() { + let total = 0; + for (const arr of Template.renderedTemplates.values()) { + total += arr.length; + } + return total; +} + +function countFor(name) { + return Template.renderedTemplates.get(name)?.length || 0; +} + +describe('heap / GC — DOM mount/unmount returns registry to empty', () => { + it('mount + unmount of a DOM cascade fixture leaves the registry clean', () => { + expect(totalRegistered()).toBe(0); + const fixture = buildDomCascade(); + expect(totalRegistered()).toBe(2); + fixture.cleanup(); + expect(totalRegistered()).toBe(0); + }); + + it('100x DOM mount/unmount cycle does not leak registry entries', () => { + for (let i = 0; i < 100; i++) { + const fixture = buildDomCascade({ + parentTemplateName: 'cycleParent', + childTemplateName: 'cycleChild', + }); + fixture.cleanup(); + } + expect(totalRegistered()).toBe(0); + }); + + it('child host detachment + onDestroyed clears entry from registry', () => { + const fixture = buildDomCascade(); + expect(countFor('uiPanel')).toBe(1); + expect(countFor('uiPanels')).toBe(1); + + fixture.childHost.remove(); + fixture.childTpl.onDestroyed(); + expect(countFor('uiPanel')).toBe(0); + expect(countFor('uiPanels')).toBe(1); + + fixture.parentTpl.onDestroyed(); + fixture.parentHost.remove(); + expect(totalRegistered()).toBe(0); + }); +}); diff --git a/packages/templating/test/data-context.test.js b/packages/templating/test/data-context.test.js new file mode 100644 index 000000000..281d250be --- /dev/null +++ b/packages/templating/test/data-context.test.js @@ -0,0 +1,649 @@ +// Pure-logic tests for createReactiveState, getDataContext, setDataContext, +// overlaySettingsSignals, and markRendered. Render coordination (engine call +// counts) lives in test/browser/data-context-render.test.js. + +import { afterEach, describe, expect, it } from 'vitest'; + +import { Reaction, Signal } from '@semantic-ui/reactivity'; +import { Renderer, ServerRenderer } from '@semantic-ui/renderer'; +import { Template } from '@semantic-ui/templating'; +import { extend } from '@semantic-ui/utils'; + +const realEngine = { renderer: Renderer, serverRenderer: ServerRenderer }; + +afterEach(() => { + Template.renderedTemplates.clear(); + Template.templateCount = 0; +}); + +/******************************* + createReactiveState +*******************************/ + +describe('Template — createReactiveState', () => { + it('wraps each defaultState entry in a Signal', () => { + const template = new Template({ + defaultState: { count: 0, name: 'jack' }, + }); + expect(template.state.count).toBeInstanceOf(Signal); + expect(template.state.name).toBeInstanceOf(Signal); + }); + + it('initializes simple { count: 0 } config as Signal(0)', () => { + const template = new Template({ + defaultState: { count: 0 }, + }); + expect(template.state.count.peek()).toBe(0); + }); + + it('forwards options for complex { value, options } config', () => { + // Custom equalityFunction lets us prove options reached the Signal: + // strict-reference equality treats two structurally-equal objects as + // changed, which the default deep equality would not. + const strictEquality = (a, b) => a === b; + const template = new Template({ + defaultState: { + config: { + value: { x: 1 }, + options: { equalityFunction: strictEquality, allowClone: false }, + }, + }, + }); + const signal = template.state.config; + expect(signal).toBeInstanceOf(Signal); + expect(signal.peek()).toEqual({ x: 1 }); + let observed = 0; + const r = Reaction.create(() => { + signal.get(); + observed++; + }); + Reaction.flush(); + const before = observed; + signal.set({ x: 1 }); + Reaction.flush(); + expect(observed).toBeGreaterThan(before); + r.stop(); + }); + + it('returns {} when defaultState is undefined', () => { + const template = new Template(); + expect(template.state).toEqual({}); + }); + + it('uses defaultState as-is when data is undefined', () => { + const template = new Template({ + defaultState: { count: 5 }, + }); + expect(template.state.count.peek()).toBe(5); + }); + + it('lets truthy data override defaultState', () => { + const template = new Template({ + defaultState: { count: 5 }, + data: { count: 10 }, + }); + expect(template.state.count.peek()).toBe(10); + }); + + it('falls back to default when data omits the key', () => { + const template = new Template({ + defaultState: { count: 5 }, + data: {}, + }); + expect(template.state.count.peek()).toBe(5); + }); + + /*------------------- + Falsy overrides + --------------------*/ + + // Falsy values (0, false, '', null) on data are treated as overrides — any + // defined data value seeds the Signal, matching subtemplate-settings parity. + + describe('falsy data values override defaultState', () => { + it('seeds Signal with 0 when data: { count: 0 }', () => { + const template = new Template({ + defaultState: { count: 5 }, + data: { count: 0 }, + }); + expect(template.state.count.peek()).toBe(0); + }); + + it('seeds Signal with false when data: { active: false }', () => { + const template = new Template({ + defaultState: { active: true }, + data: { active: false }, + }); + expect(template.state.active.peek()).toBe(false); + }); + + it('seeds Signal with empty string when data: { name: "" }', () => { + const template = new Template({ + defaultState: { name: 'default' }, + data: { name: '' }, + }); + expect(template.state.name.peek()).toBe(''); + }); + + it('seeds Signal with null when data: { value: null }', () => { + const template = new Template({ + defaultState: { value: 'default' }, + data: { value: null }, + }); + expect(template.state.value.peek()).toBe(null); + }); + }); +}); + +/******************************* + getDataContext +*******************************/ + +// The data context is a flat merge: `extend({}, data, state, instance)`. +// Last-source-wins gives instance > state > data precedence. Settings are +// NOT included here — they enter via overlaySettingsSignals. + +describe('Template — getDataContext merge order', () => { + it('returns data only when only data is set', () => { + const template = new Template({ + data: { name: 'jack' }, + }); + const ctx = template.getDataContext(); + expect(ctx).toEqual({ name: 'jack' }); + }); + + it('returns state Signals only when only defaultState is set', () => { + const template = new Template({ + defaultState: { count: 7 }, + }); + const ctx = template.getDataContext(); + expect(ctx.count).toBeInstanceOf(Signal); + expect(ctx.count.peek()).toBe(7); + }); + + it('includes instance properties from createComponent', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + createComponent: () => ({ greet: 'hi' }), + }); + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.greet).toBe('hi'); + }); + + it('lets state Signal win over data on key collision', () => { + const template = new Template({ + defaultState: { name: 'sally' }, + data: { name: 'jack' }, + }); + const ctx = template.getDataContext(); + expect(ctx.name).toBeInstanceOf(Signal); + expect(ctx.name.peek()).toBe('jack'); + }); + + it('lets instance win over data on key collision', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + data: { name: 'jack' }, + createComponent: () => ({ name: 'bob' }), + }); + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.name).toBe('bob'); + }); + + it('lets instance win over state on key collision', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + defaultState: { name: 'sally' }, + createComponent: () => ({ name: 'bob' }), + }); + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.name).toBe('bob'); + }); + + it('lets instance win across all three layers', () => { + const template = new Template({ + template: '
    ', + renderingEngine: realEngine, + data: { value: 'data' }, + defaultState: { value: 'state' }, + createComponent: () => ({ value: 'instance' }), + }); + template.initialize(); + const ctx = template.getDataContext(); + expect(ctx.value).toBe('instance'); + }); + + it('returns a fresh object on every call', () => { + const template = new Template({ + data: { name: 'jack' }, + }); + const a = template.getDataContext(); + const b = template.getDataContext(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); + + it('does not include settings (settings enter via overlay)', () => { + const fakeElement = { + settings: { color: 'blue' }, + settingsVars: new Map([['color', new Signal('blue')]]), + defaultSettings: { color: 'blue' }, + }; + const template = new Template({ + element: fakeElement, + }); + const ctx = template.getDataContext(); + expect(ctx.color).toBeUndefined(); + }); +}); + +/******************************* + setDataContext +*******************************/ + +describe('Template — setDataContext', () => { + it('merges new keys into this.data', () => { + const template = new Template({ + data: { a: 1 }, + }); + template.setDataContext({ a: 1, b: 2 }); + expect(template.data).toEqual({ a: 1, b: 2 }); + }); + + it('sets dataReplaced=true when something changes', () => { + const template = new Template({ + data: { a: 1 }, + }); + template.dataReplaced = false; + template.setDataContext({ a: 1, b: 2 }); + expect(template.dataReplaced).toBe(true); + }); + + it('does not set dataReplaced when nothing changes', () => { + const template = new Template({ + data: { a: 1, b: 2 }, + }); + template.dataReplaced = false; + template.setDataContext({ a: 1, b: 2 }); + expect(template.dataReplaced).toBe(false); + }); + + it('default { rerender: true } resets this.rendered to false', () => { + const template = new Template({ + data: { a: 1 }, + }); + template.rendered = true; + template.setDataContext({ a: 2 }); + expect(template.rendered).toBe(false); + }); + + it('{ rerender: false } preserves this.rendered', () => { + const template = new Template({ + data: { a: 1 }, + }); + template.rendered = true; + template.setDataContext({ a: 2 }, { rerender: false }); + expect(template.rendered).toBe(true); + }); + + it('deletes orphaned keys silently', () => { + // assignInPlace strips keys not in the source unless preserveExistingKeys + // is set; setDataContext does not pass it, so missing keys disappear. + const template = new Template({ + data: { a: 1, b: 2 }, + }); + template.setDataContext({ a: 1 }); + expect(template.data).toEqual({ a: 1 }); + expect('b' in template.data).toBe(false); + }); +}); + +/******************************* + overlaySettingsSignals +*******************************/ + +describe('Template — overlaySettingsSignals (subtemplate path)', () => { + it('is a no-op when settingsVars is not set', () => { + const parent = new Template(); + const child = new Template({ + defaultSettings: { color: 'blue' }, + }); + child.setParent(parent); + const ctx = { existing: 'value' }; + const result = child.overlaySettingsSignals(ctx); + expect(result).toBe(ctx); + expect(result.color).toBeUndefined(); + }); + + it('is a no-op when defaultSettings is missing', () => { + const parent = new Template(); + const child = new Template(); + child.setParent(parent); + child.settingsVars = new Map([['color', new Signal('red')]]); + const ctx = {}; + child.overlaySettingsSignals(ctx); + expect(ctx.color).toBeUndefined(); + }); + + it('overlays Signals from settingsVars onto context', () => { + const parent = new Template(); + const child = new Template({ + defaultSettings: { color: 'blue' }, + }); + child.setParent(parent); + // Manual settingsVars wiring stands in for createSubtemplateSettings; the + // settings stand-in just needs to read so overlay can walk defaultSettings. + const colorSignal = new Signal('red'); + child.settingsVars = new Map([['color', colorSignal]]); + child.settings = { color: 'red' }; + const ctx = {}; + child.overlaySettingsSignals(ctx); + expect(ctx.color).toBe(colorSignal); + expect(ctx.color.peek()).toBe('red'); + }); + + it('makes the Signal win over a plain duplicate from the spread', () => { + const parent = new Template(); + const child = new Template({ + defaultSettings: { color: 'blue' }, + }); + child.setParent(parent); + const colorSignal = new Signal('red'); + child.settingsVars = new Map([['color', colorSignal]]); + child.settings = { color: 'red' }; + const ctx = { color: 'plain-string' }; + child.overlaySettingsSignals(ctx); + expect(ctx.color).toBe(colorSignal); + }); +}); + +describe('Template — overlaySettingsSignals (web component path)', () => { + it('is a no-op when element has no settingsVars', () => { + const fakeElement = { settings: {} }; + const template = new Template({ + element: fakeElement, + }); + const ctx = {}; + template.overlaySettingsSignals(ctx); + expect(ctx).toEqual({}); + }); + + it('overlays each settingsVars entry as a Signal onto context', () => { + const colorSignal = new Signal('blue'); + const sizeSignal = new Signal('small'); + const fakeElement = { + settings: { color: 'blue', size: 'small' }, + settingsVars: new Map([ + ['color', colorSignal], + ['size', sizeSignal], + ]), + defaultSettings: { color: 'blue', size: 'small' }, + }; + const template = new Template({ + element: fakeElement, + }); + const ctx = {}; + template.overlaySettingsSignals(ctx); + expect(ctx.color).toBe(colorSignal); + expect(ctx.size).toBe(sizeSignal); + }); + + it('touches each defaultSettings key to drive shadow Signal creation', () => { + // Reading element.settings[name] for each defaultSettings key triggers + // the settings proxy getter, which lazy-creates Signals in production. + const reads = []; + const fakeElement = { + settings: new Proxy({ color: 'blue', size: 'small' }, { + get: (target, prop) => { + if (typeof prop === 'string') { + reads.push(prop); + } + return target[prop]; + }, + }), + settingsVars: new Map([['color', new Signal('blue')]]), + defaultSettings: { color: 'blue', size: 'small' }, + }; + const template = new Template({ + element: fakeElement, + }); + template.overlaySettingsSignals({}); + expect(reads).toContain('color'); + expect(reads).toContain('size'); + }); + + it('touches each componentSpec.attributes key to drive spec-attribute Signals', () => { + const reads = []; + const fakeElement = { + settings: new Proxy({ color: 'blue', active: false, size: 'm' }, { + get: (target, prop) => { + if (typeof prop === 'string') { + reads.push(prop); + } + return target[prop]; + }, + }), + settingsVars: new Map([['color', new Signal('blue')]]), + defaultSettings: { color: 'blue' }, + componentSpec: { + attributes: ['active', 'size'], + }, + }; + const template = new Template({ + element: fakeElement, + }); + template.overlaySettingsSignals({}); + expect(reads).toContain('active'); + expect(reads).toContain('size'); + }); + + it('returns the context object passed in', () => { + const fakeElement = { + settings: {}, + settingsVars: new Map(), + defaultSettings: {}, + }; + const template = new Template({ + element: fakeElement, + }); + const ctx = { existing: 1 }; + const result = template.overlaySettingsSignals(ctx); + expect(result).toBe(ctx); + }); +}); + +/*------------------- + Settings vs state +--------------------*/ + +// State wins over data within getDataContext, but a setting backed by a +// Signal in element.settingsVars overrides state with the same name because +// overlaySettingsSignals runs after the spread. + +describe('Template — settings overlay vs state precedence', () => { + it('settings Signal in settingsVars overrides state Signal of same name', () => { + const settingsColorSignal = new Signal('settings-blue'); + const fakeElement = { + settings: { color: 'settings-blue' }, + settingsVars: new Map([['color', settingsColorSignal]]), + defaultSettings: { color: 'settings-blue' }, + }; + const template = new Template({ + element: fakeElement, + defaultState: { color: 'state-red' }, + }); + const ctx = template.getDataContext(); + expect(ctx.color).toBeInstanceOf(Signal); + expect(ctx.color.peek()).toBe('state-red'); + + template.overlaySettingsSignals(ctx); + expect(ctx.color).toBe(settingsColorSignal); + expect(ctx.color.peek()).toBe('settings-blue'); + }); +}); + +/******************************* + extend semantics +*******************************/ + +// getDataContext relies on extend's shallow last-source-wins semantics. Pin +// the contract here so a regression (e.g. switching to deepExtend) breaks at +// the surface where it actually matters. + +describe('Template — extend (utils) shallow last-wins', () => { + it('extend({}, a, b, c) merges shallowly with last source winning', () => { + const a = { name: 'a', age: 1 }; + const b = { name: 'b', city: 'NYC' }; + const c = { name: 'c' }; + const result = extend({}, a, b, c); + expect(result).toEqual({ name: 'c', age: 1, city: 'NYC' }); + }); + + it('is shallow — nested objects replace, not merge', () => { + const a = { settings: { color: 'red', size: 'large' } }; + const b = { settings: { color: 'blue' } }; + const result = extend({}, a, b); + expect(result.settings).toEqual({ color: 'blue' }); + expect(result.settings.size).toBeUndefined(); + }); + + it('mutates first arg and returns it', () => { + const target = { a: 1 }; + const result = extend(target, { b: 2 }); + expect(result).toBe(target); + expect(target).toEqual({ a: 1, b: 2 }); + }); +}); + +/******************************* + markRendered +*******************************/ + +describe('Template — markRendered', () => { + it('sets rendered=true and destroyed=false', () => { + const template = new Template(); + template.markRendered(); + expect(template.rendered).toBe(true); + expect(template.destroyed).toBe(false); + }); + + it('is idempotent', () => { + const template = new Template(); + template.markRendered(); + template.markRendered(); + template.markRendered(); + expect(template.rendered).toBe(true); + expect(template.destroyed).toBe(false); + }); + + it('revives a destroyed template', () => { + const template = new Template(); + template.destroyed = true; + template.rendered = false; + template.markRendered(); + expect(template.rendered).toBe(true); + expect(template.destroyed).toBe(false); + }); +}); + +/*------------------- + dataReplaced flag +--------------------*/ + +// The first-render branch in render() never clears dataReplaced — only the +// follow-up else-if branch does. After the initial walk-through the flag +// remains stuck-true; pin the current behavior so changes are visible. + +describe('Template — dataReplaced flag is sticky on first render', () => { + it('first-render walk-through leaves dataReplaced true', () => { + const template = new Template({ + data: { a: 1 }, + }); + template.dataReplaced = false; + template.setDataContext({ a: 1, b: 2 }, { rerender: false }); + expect(template.dataReplaced).toBe(true); + template.rendered = true; + expect(template.dataReplaced).toBe(true); + }); +}); + +/*------------------- + Subtemplate parity +--------------------*/ + +// createSubtemplateSettings uses `!== undefined` to seed settings from data, +// so falsy values (0, false, '', null) take effect. These tests guard parity +// with createReactiveState's own falsy-override behavior above. + +describe('Template — subtemplate settings respects falsy data', () => { + it('seeds from data: { count: 0 }', () => { + const parent = new Template(); + const child = new Template({ + template: '', + renderingEngine: realEngine, + defaultSettings: { count: 5 }, + data: { count: 0 }, + }); + child.setParent(parent); + child.initialize(); + expect(child.settings.count).toBe(0); + }); + + it('seeds from data: { active: false }', () => { + const parent = new Template(); + const child = new Template({ + template: '', + renderingEngine: realEngine, + defaultSettings: { active: true }, + data: { active: false }, + }); + child.setParent(parent); + child.initialize(); + expect(child.settings.active).toBe(false); + }); + + it('seeds from data: { name: "" }', () => { + const parent = new Template(); + const child = new Template({ + template: '', + renderingEngine: realEngine, + defaultSettings: { name: 'default' }, + data: { name: '' }, + }); + child.setParent(parent); + child.initialize(); + expect(child.settings.name).toBe(''); + }); + + it('seeds from data: { value: null }', () => { + const parent = new Template(); + const child = new Template({ + template: '', + renderingEngine: realEngine, + defaultSettings: { value: 'default' }, + data: { value: null }, + }); + child.setParent(parent); + child.initialize(); + expect(child.settings.value).toBe(null); + }); + + it('falls through to default when data omits the key', () => { + const parent = new Template(); + const child = new Template({ + template: '', + renderingEngine: realEngine, + defaultSettings: { value: 'default' }, + data: {}, + }); + child.setParent(parent); + child.initialize(); + expect(child.settings.value).toBe('default'); + }); +}); diff --git a/packages/templating/test/dom/key-bindings.test.js b/packages/templating/test/dom/key-bindings.test.js new file mode 100644 index 000000000..c7be92bd3 --- /dev/null +++ b/packages/templating/test/dom/key-bindings.test.js @@ -0,0 +1,607 @@ +// Tests for Template's `keys` binding system: single keys, comma-list +// alternates, modifier combos, sequences with the 500ms timeout, the +// inputFocused/repeatedKey/event callback extras, the return-value contract, +// dynamic bindKey/unbindKey, the SSR guard, and AbortController cleanup. +// +// jsdom is enough — Template's keydown listener attaches to document and +// there is no shadow DOM dependency for keys. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Template } from '@semantic-ui/templating'; + +afterEach(() => { + document.body.innerHTML = ''; + Template.renderedTemplates.clear(); + Template.templateCount = 0; +}); + +/** + * Construct a Template with the given keys and prime its eventController so + * bindKeys() registers document listeners that can be aborted for cleanup. + * Returns { template, element, root } with listeners already installed; + * abort via `template.eventController.abort()`. + */ +function makeKeyTemplate(opts = {}) { + const element = document.createElement('div'); + document.body.appendChild(element); + const root = document.createElement('div'); + element.appendChild(root); + + const template = new Template({ + element, + ...opts, + }); + // Stub `instance` so buildCallParams (used by template.call inside the + // keydown handler) can read `instance.content` without throwing. Real + // initialize() builds this for us; we skip initialize() here to keep the + // tests narrowly focused on bindKeys. + template.instance = {}; + template.eventController = new AbortController(); + template.bindKeys(); + + return { template, element, root }; +} + +/** Dispatch a keydown + keyup pair for a key. */ +function pressKey(key, init = {}) { + document.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true, ...init }), + ); + document.dispatchEvent( + new KeyboardEvent('keyup', { key, bubbles: true, composed: true, cancelable: true, ...init }), + ); +} + +/** Dispatch a sequence of keys (each a full keydown+keyup). */ +function pressKeys(keys) { + for (const key of keys) { + pressKey(key); + } +} + +/** Dispatch a key with modifiers held: { ctrl, shift, alt, meta }. */ +function pressKeyCombo(key, mods = {}) { + const init = { + key, + bubbles: true, + composed: true, + cancelable: true, + ctrlKey: !!mods.ctrl, + shiftKey: !!mods.shift, + altKey: !!mods.alt, + metaKey: !!mods.meta, + }; + document.dispatchEvent(new KeyboardEvent('keydown', init)); + document.dispatchEvent(new KeyboardEvent('keyup', init)); +} + +/** Dispatch a keydown only (no matching keyup). For repeatedKey tests. */ +function pressKeyDown(key, init = {}) { + document.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, composed: true, cancelable: true, ...init }), + ); +} + +/** Dispatch a keyup only. */ +function pressKeyUp(key, init = {}) { + document.dispatchEvent( + new KeyboardEvent('keyup', { key, bubbles: true, composed: true, cancelable: true, ...init }), + ); +} + +describe('Template — key bindings', () => { + /******************************* + Single-key descriptors + *******************************/ + + describe('single-key descriptor', () => { + it('fires the handler when the registered key is pressed', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ keys: { esc: handler } }); + pressKey('Escape'); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('does not fire on unrelated keys', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ keys: { esc: handler } }); + pressKey('a'); + pressKey('b'); + expect(handler).not.toHaveBeenCalled(); + template.eventController.abort(); + }); + + it('normalizes uppercase key presses to lowercase', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ keys: { a: handler } }); + pressKey('A'); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('calls preventDefault when the handler returns undefined', () => { + const { template } = makeKeyTemplate({ + keys: { esc: () => undefined }, + }); + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'preventDefault'); + document.dispatchEvent(event); + expect(spy).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('does NOT call preventDefault when the handler returns true', () => { + const { template } = makeKeyTemplate({ + keys: { esc: () => true }, + }); + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'preventDefault'); + document.dispatchEvent(event); + expect(spy).not.toHaveBeenCalled(); + template.eventController.abort(); + }); + + it('calls preventDefault when the handler returns false (only === true opts out)', () => { + const { template } = makeKeyTemplate({ + keys: { esc: () => false }, + }); + const event = new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }); + const spy = vi.spyOn(event, 'preventDefault'); + document.dispatchEvent(event); + expect(spy).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + }); + + /******************************* + Comma-separated descriptors + *******************************/ + + describe('comma-separated descriptors', () => { + it("fires for the first listed key from a cold buffer ('up, down')", () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'up, down': handler }, + }); + pressKey('ArrowUp'); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('fires for the second listed key from a cold buffer', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'up, down': handler }, + }); + pressKey('ArrowDown'); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('fires for the second listed key after a prior keystroke seeded the buffer', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'up, down': handler }, + }); + pressKey('ArrowUp'); + handler.mockClear(); + pressKey('ArrowDown'); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it("parses cleanly with extra whitespace ('up , down ')", () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'up , down ': handler }, + }); + pressKey('ArrowUp'); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('does not fire on every keystroke when the descriptor is a bare comma', () => { + // ','.split(',') yields ['',''] and endsWith('') is always true, + // so without empty-string filtering the handler fires on every key. + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { ',': handler }, + }); + pressKey('a'); + pressKey('b'); + pressKey('c'); + expect(handler).not.toHaveBeenCalled(); + template.eventController.abort(); + }); + }); + + /******************************* + Modifier combinations + *******************************/ + + describe('modifier combinations with +', () => { + it("fires on Ctrl+F when registered as 'ctrl + f'", () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'ctrl + f': handler }, + }); + pressKeyCombo('f', { ctrl: true }); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it("treats 'ctrl + f' and 'ctrl+f' as identical (spacing around + is normalized)", () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'ctrl+f': handler }, + }); + pressKeyCombo('f', { ctrl: true }); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('fires on multi-modifier combos (ctrl + shift + a)', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'ctrl+shift+a': handler }, + }); + pressKeyCombo('a', { ctrl: true, shift: true }); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('does not fire when only some modifiers are pressed', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'ctrl+f': handler }, + }); + pressKey('f'); + expect(handler).not.toHaveBeenCalled(); + template.eventController.abort(); + }); + }); + + /******************************* + Key sequences + *******************************/ + + describe('key sequences (space-separated)', () => { + it("fires when the second key is pressed within 500ms ('g i')", () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'g i': handler }, + }); + pressKeys(['g', 'i']); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + + it('does NOT fire when the second key arrives after the 500ms timeout', () => { + vi.useFakeTimers(); + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'g i': handler }, + }); + try { + pressKey('g'); + vi.advanceTimersByTime(501); + pressKey('i'); + expect(handler).not.toHaveBeenCalled(); + } + finally { + template.eventController.abort(); + vi.useRealTimers(); + } + }); + + it('extends the timeout on each press (sliding window)', () => { + vi.useFakeTimers(); + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'g i': handler }, + }); + try { + pressKey('g'); + vi.advanceTimersByTime(400); + pressKey('i'); + expect(handler).toHaveBeenCalledTimes(1); + } + finally { + template.eventController.abort(); + vi.useRealTimers(); + } + }); + + it('matches a space-separated sequence regardless of comma-list parsing', () => { + const handler = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { 'g i': handler }, + }); + pressKeys(['g', 'i']); + expect(handler).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + }); + + /******************************* + Single + sequence co-fire + *******************************/ + + describe('single-key and matching-suffix sequence co-fire', () => { + it("fires both 'i' and 'g i' handlers when a sequence completes", () => { + // The buffer 'g i' satisfies endsWith('g i') and endsWith('i'), so both + // descriptors match. + const single = vi.fn(); + const sequence = vi.fn(); + const { template } = makeKeyTemplate({ + keys: { i: single, 'g i': sequence }, + }); + pressKeys(['g', 'i']); + expect(single).toHaveBeenCalledTimes(1); + expect(sequence).toHaveBeenCalledTimes(1); + template.eventController.abort(); + }); + }); + + /******************************* + Callback param: inputFocused + *******************************/ + + describe('inputFocused callback param', () => { + it('is true when an is focused', () => { + const handler = vi.fn(); + const { template, element } = makeKeyTemplate({ + keys: { esc: handler }, + }); + const input = document.createElement('input'); + element.appendChild(input); + input.focus(); + pressKey('Escape'); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].inputFocused).toBe(true); + template.eventController.abort(); + }); + + it('is true when a