fix: use numeric constant instead of WebSocket.OPEN#3
Conversation
In certain contexts (e.g., during WebSocket reconnection or in isolated execution environments), the WebSocket global object's constants may be undefined even when the WebSocket connection is active. This causes readyState comparisons like to fail (comparing 1 === undefined), triggering incorrect HTTP fallback even when the WebSocket is open and ready. Fix by using the numeric constant 1 directly instead of WebSocket.OPEN. This ensures the readyState check works reliably regardless of whether the WebSocket global constants are available. Fixes issue where delete operations would fall back to HTTP in full test suites despite having an open WebSocket connection.
Code Review - PR #3: Fix WebSocket.OPEN constant issueOverviewThis PR addresses a critical bug where ✅ Code Quality & Best PracticesStrengths:
Suggestions:
🐛 Potential Bugs & IssuesCritical observation:
Potential issue in livetemplate-client.ts:425-431: } else if (readyState !== undefined) {
// WebSocket is connecting or closing, fall back to HTTP temporarily
this.logger.warn(
`WebSocket not ready (state: ${readyState}), using HTTP fallback`
);This catches all non-OPEN states (0, 2, 3). If Recommendation: If you ever need to check for specific states (e.g., to handle CLOSING differently than CONNECTING), ensure you use numeric constants throughout: if (readyState === 0) { // CONNECTING
// handle connecting
} else if (readyState === 2 || readyState === 3) { // CLOSING or CLOSED
// handle closing/closed
}⚡ Performance ConsiderationsGood:
🔒 Security ConcernsNo security issues identified. The change is purely a comparison operation and doesn't introduce any vulnerabilities. 🧪 Test CoverageObservations:
Recommendations: describe('WebSocket readyState handling', () => {
it('should use WebSocket when readyState is 1 even if WebSocket.OPEN is undefined', () => {
// Mock scenario where WebSocket.OPEN is undefined
const originalOpen = WebSocket.OPEN;
Object.defineProperty(WebSocket, 'OPEN', { value: undefined, configurable: true });
// Test that isReady() and send() still work correctly
// ... test implementation
// Restore
Object.defineProperty(WebSocket, 'OPEN', { value: originalOpen, configurable: true });
});
});📊 Overall AssessmentScore: 8.5/10 Pros:
Areas for improvement:
🎯 RecommendationAPPROVE with minor suggestions This is a solid fix for a legitimate bug. The code change is correct and addresses the immediate issue. The suggestions above are for future-proofing and improved maintainability, but they're not blockers for merging this PR. The fix is production-ready as-is, especially given that e2e tests are passing. |
There was a problem hiding this comment.
Pull request overview
This PR addresses a critical bug where WebSocket connection state checks fail due to WebSocket.OPEN becoming undefined in certain execution contexts (e.g., during reconnection cycles). The fix replaces symbolic constant references with the numeric value 1, ensuring reliable WebSocket state checking.
Key Changes:
- Replaced
WebSocket.OPENwith numeric constant1to avoid undefined reference issues - Added inline comments documenting the numeric constant meaning
- Applied fix consistently across WebSocket transport layer and client code
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
transport/websocket.ts |
Updated send() method to use numeric constant 1 instead of WebSocket.OPEN |
livetemplate-client.ts |
Updated isReady() and send() methods to use numeric constant 1 for WebSocket state checks |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| send(data: string): void { | ||
| if (this.socket && this.socket.readyState === WebSocket.OPEN) { | ||
| if (this.socket && this.socket.readyState === 1) { // WebSocket.OPEN = 1 |
There was a problem hiding this comment.
[nitpick] Consider adding a named constant for the WebSocket ready state to constants.ts (following the existing pattern in the codebase) instead of using the magic number 1. This would improve code maintainability and consistency across all three locations where this check is used.
Example addition to constants.ts:
export const WS_READY_STATE_OPEN = 1; // WebSocket.OPEN constantThen import and use it:
import { WS_READY_STATE_OPEN } from "../constants";
// ...
if (this.socket && this.socket.readyState === WS_READY_STATE_OPEN) {This approach provides the reliability of numeric constants while maintaining code clarity and following established codebase patterns.
|
|
||
| const readyState = this.webSocketManager.getReadyState(); | ||
| return readyState === WebSocket.OPEN; | ||
| return readyState === 1; // WebSocket.OPEN = 1 |
There was a problem hiding this comment.
[nitpick] Consider using a named constant from constants.ts instead of the magic number 1. This follows the established pattern in the codebase (e.g., FOCUSABLE_INPUTS) and would improve maintainability. The constant should be shared across all three usages (lines 389, 417, and transport/websocket.ts:60).
| (window as any).__lvtSendPath = "http"; | ||
| this.sendHTTP(message); | ||
| } else if (readyState === WebSocket.OPEN) { | ||
| } else if (readyState === 1) { // WebSocket.OPEN = 1 |
There was a problem hiding this comment.
[nitpick] Same as line 389: consider using a named constant from constants.ts instead of the magic number 1 for consistency.
Bug fixes from Claude + Copilot review on PR #108: 1. **findContainer no longer falls back to unscoped wrapper walk** (Claude #1) Removed `wrapper.querySelector('[data-key]')` fallback that could silently return a container belonging to a *different* keyed range when the wrapper has more than one. Returns null instead so the caller falls back to full rebuild. 2. **applyUpdateRow no-morphdom path now fires lifecycle hooks + notifies host** (Claude #2) The `row.replaceWith(newRow)` fallback never fired lvt-mounted/lvt-destroyed and didn't increment nodesAddedThisRender, so post-render directive scans were incorrectly skipped on rows containing newly-revealed children. 3. **Override childrenOnly:false for per-row morph** (Copilot #1) The main morphdom call uses childrenOnly:true (correct for the wrapper). For a single-row morph we must diff the row root too — class/style/aria attributes produced by statics+dynamics may have changed. 4. **Robust handling of failed targeted ops** (Copilot #2) When apply() returns null, the treeState was updated but the live DOM wasn't. Previously we'd still convert placeholders → either tell morphdom to skip (stale DOM) or leave an empty container (10k rows destroyed). Now we re-render full HTML from treeState (new `TreeRenderer.renderState()`), strip any partial success markers, and let morphdom sync. 5. **lvt-ignore check now includes wrapper itself** (Copilot #3) The walk previously stopped *before* checking the wrapper, allowing targeted-apply to mutate DOM inside an lvt-ignore'd wrapper. 6. **applyReorder logs a warning when newKeyOrder is shorter than children** (Claude #3) Documents the protocol assumption (server always sends full new order) and surfaces silent data-loss if the protocol changes. 7. **CSS escape fallback warns on unsupported chars** (Claude #4) Polyfill only handles `"` and `\`; warn (not silently miss) when keys contain `[]:.()#>~+*=^$|! ` whitespace etc. Tests: - 4 new range-dom-applier tests covering (1), (2), (3), (5) - Fixture pre-populates container cache via canApplyTargeted-equivalent call so a/p ops can locate the container (mirrors production flow now that the unscoped fallback is gone) - 510 unit tests pass (was 507 — net +3) - TestLargeTable + TestLargeTable_DeleteLatency_10k green at 1958 ms (ceiling 2500 ms), targeted-apply hits=1 - TestTodosE2E full suite green
Three Claude follow-up findings on commit 3c99b99: 1. **applyReorder fires lvt-destroyed on dropped children** When `newKeyOrder` is shorter than the existing children set, dropped children were silently discarded by `replaceChildren(fragment)`. Any user teardown registered via lvt-destroyed (timer cancellation, observer disconnect, etc.) never ran. Now: walk byKey for entries missing from the new order set, fire lvt-destroyed on each subtree before the replaceChildren. 2. **Directive-touched signal complements nodes-added signal** The `nodesAddedThisRender > 0` skip-scan optimization missed the case where the server adds a directive attribute to an existing element via attribute morph (e.g. `lvt-fx:keydown` on a button that didn't have it). Without scanning, the listener never wires and the user has to know about `data-lvt-force-update` as the escape hatch — which isn't publicly documented. Now morphdom.onBeforeElUpdated checks toEl for any `lvt-*` attribute and sets `directiveTouchedThisRender = true`. The scan gate becomes `nodesAdded > 0 || directiveTouched`, preserving the delete-only fast path while wiring listeners on attribute-only morphs that introduce new directives. Cost: per onBeforeElUpdated call, scan toEl.attributes for any name starting with "lvt-" (4-char prefix check via charCodeAt to avoid String.startsWith allocation overhead). At 80k descendants that's ~10ms — well below the post-render scans we'd otherwise run unnecessarily. 3. **__lvtTargetedHits gated behind explicit init** Previously incremented `window.__lvtTargetedHits` unconditionally, which polluted the global object in production. Now: increments only when the property is already present on `window` — tests initialize it via `window.__lvtTargetedHits = 0` before measuring; production never sets it so the increment branch is skipped. 4. **itemLookup O(N) acknowledged with comment** (Claude #3, low pri) Linear scan is bounded; building a Map<key,item> per render would amortize multi-u-op renders but adds caching complexity. Documented the trade-off in the lookup callback; revisit if profiling shows it on the hot path. Tests: 514 unit pass (+1 new applyReorder lvt-destroyed test). TestLargeTable + TestLargeTable_DeleteLatency_10k green at 1798ms, targeted-apply hits=1.
* feat: per-op targeted DOM mutation for range diff ops (#107) Bypasses full tree HTML reconstruction + morphdom diff for keyed-range diff ops (r/u/i/a/p/o). The targeted-apply path mutates the live DOM directly via [data-key="..."] queries, while a sentinel comment + data-lvt-targeted-skip marker tells morphdom to short-circuit the subtree on coexisting sibling updates. Pre-fix, single-row delete on a 10k-row table took 6-8 seconds in Chrome desktop because the client deep-cloned 10k items, rebuilt 5MB of HTML, and ran morphdom over the entire range. Post-fix, the same op completes in ~1.7s wall-clock (server compute + WS + targeted DOM mutation + post-render scans). The targeted-apply portion itself is sub-100ms; the residual cost is in post-morphdom side-effect rescans (handleScrollDirectives, changeAutoWirer.wireElements) which still walk the wrapper at O(N) — that's a follow-up. Architecture: - New state/range-dom-applier.ts: per-op DOM mutation module with findContainer + canApplyTargeted + apply (r/u/i/a/p/o) + cleanupMarkers - state/tree-renderer.ts: extracted renderRangeItem helper; applyUpdate now takes optional canApplyTargeted callback, mutates treeState in place for eligible keys, returns targetedOps + emits comment placeholder in result.html for skipped ranges - livetemplate-client.ts updateDOM: dispatches targeted ops before morphdom; post-processes placeholder comments; morphdom's onBeforeElUpdated returns false for marked subtrees; cleanup in try/finally - types.ts: TargetedRangeOp interface + optional UpdateResult.targetedOps field (additive, no breakage for downstream consumers) Includes 23 jsdom unit tests in tests/range-dom-applier.test.ts (per-op coverage, focus preservation, lifecycle hooks, skip mechanism with morphdom). Closes #107 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: add window.__lvtTargetedHits observability hook for tests (#107) E2E tests assert the targeted-apply path was actually taken (vs silently hitting the fallback). Cost: one integer increment per applied op. * perf(client): skip wrapper-wide directive scans when no nodes added (#107) Profile of a 10k-row delete revealed ~360ms wasted in post-render directive scans (setupFxDOMEventTriggers, setupDOMEventTriggerDelegation, handleScrollDirectives, etc.) — each does qsa("*") on the wrapper, which contains ~80k descendants at N=10k. For a delete-only render, no new elements need wiring, so all wrapper-wide scans become pure waste. Track DOM additions via: - Existing morphdom onNodeAdded callback (now also increments counter) - New onNodeAdded callback in RangeDomApplier context, fired by i/a/p ops When `nodesAddedThisRender === 0` after both phases, skip the scans. changeAutoWirer.wireElements still runs (it has its own eviction loop for stale wirings on removed elements). Measured impact (10k-row LargeTable delete in headless Chrome): - updateDOM: 692ms → 447ms (-35%) - Browser-side wall-clock: ~1500ms → ~1170ms (-22%) Limitation: if the server adds new directive attributes to an existing element via attribute morph (e.g. adds lvt-fx:keydown to a button that didn't have it), the listener won't be wired until a render that DOES add a node fires the next scan. Rare in practice; opt out by adding data-lvt-force-update on the affected element. * fix: address bot review comments (#107) Bug fixes from Claude + Copilot review on PR #108: 1. **findContainer no longer falls back to unscoped wrapper walk** (Claude #1) Removed `wrapper.querySelector('[data-key]')` fallback that could silently return a container belonging to a *different* keyed range when the wrapper has more than one. Returns null instead so the caller falls back to full rebuild. 2. **applyUpdateRow no-morphdom path now fires lifecycle hooks + notifies host** (Claude #2) The `row.replaceWith(newRow)` fallback never fired lvt-mounted/lvt-destroyed and didn't increment nodesAddedThisRender, so post-render directive scans were incorrectly skipped on rows containing newly-revealed children. 3. **Override childrenOnly:false for per-row morph** (Copilot #1) The main morphdom call uses childrenOnly:true (correct for the wrapper). For a single-row morph we must diff the row root too — class/style/aria attributes produced by statics+dynamics may have changed. 4. **Robust handling of failed targeted ops** (Copilot #2) When apply() returns null, the treeState was updated but the live DOM wasn't. Previously we'd still convert placeholders → either tell morphdom to skip (stale DOM) or leave an empty container (10k rows destroyed). Now we re-render full HTML from treeState (new `TreeRenderer.renderState()`), strip any partial success markers, and let morphdom sync. 5. **lvt-ignore check now includes wrapper itself** (Copilot #3) The walk previously stopped *before* checking the wrapper, allowing targeted-apply to mutate DOM inside an lvt-ignore'd wrapper. 6. **applyReorder logs a warning when newKeyOrder is shorter than children** (Claude #3) Documents the protocol assumption (server always sends full new order) and surfaces silent data-loss if the protocol changes. 7. **CSS escape fallback warns on unsupported chars** (Claude #4) Polyfill only handles `"` and `\`; warn (not silently miss) when keys contain `[]:.()#>~+*=^$|! ` whitespace etc. Tests: - 4 new range-dom-applier tests covering (1), (2), (3), (5) - Fixture pre-populates container cache via canApplyTargeted-equivalent call so a/p ops can locate the container (mirrors production flow now that the unscoped fallback is gone) - 510 unit tests pass (was 507 — net +3) - TestLargeTable + TestLargeTable_DeleteLatency_10k green at 1958 ms (ceiling 2500 ms), targeted-apply hits=1 - TestTodosE2E full suite green * fix: silent per-op skips now propagate to apply() fallback (#107) Round-2 bot review (Claude) caught a correctness gap in the round-1 fixes: per-op methods (applyUpdateRow, applyInsertAfter, applyAppend, applyPrepend, applyReorder) had several early-return paths for stale state (row not found, item state unavailable, anchor missing, render failed). These returned silently and apply() still reported the container as a success — so updateDOM marked it with TARGETED_APPLIED_ATTR, morphdom skipped the subtree, and the live DOM stayed out of sync with treeState forever. Changes: 1. Each per-op method now returns boolean. apply() collects the result and returns null if any op silently no-op'd, triggering the updateDOM full-rebuild fallback (added in round 1). 2. applyRemove still returns true on missing row (idempotent — common when the same row got removed by an earlier op or previous render). 3. itemLookup moved from `wireItemLookup()` post-construction call into the required RangeDomApplierContext field. Eliminates the silent footgun where forgetting to wire it would silently no-op every `u` op (treeState updated, DOM stale). 4. Added 3 jsdom unit tests covering the new contract: - apply() returns null when u op finds row in DOM but item state missing - apply() returns null when i op anchor is missing - r op apply() succeeds even when row already gone (idempotency) Tests: 513 unit tests pass (+3 new). TestLargeTable + TestLargeTable DeleteLatency_10k green at 1523ms with targeted-apply hits=1. Notes for follow-up (not addressed in this PR): - Claude flagged that data-lvt-force-update (the escape hatch for attribute-only changes that skip directive scans when nodesAddedThisRender===0) isn't in user-facing docs. The inline comment in updateDOM documents it but the public docs in livetemplate/docs/references/ should be updated. Out of client repo scope; deferred. * fix: address round-3 bot review (#107) Three Claude follow-up findings on commit 3c99b99: 1. **applyReorder fires lvt-destroyed on dropped children** When `newKeyOrder` is shorter than the existing children set, dropped children were silently discarded by `replaceChildren(fragment)`. Any user teardown registered via lvt-destroyed (timer cancellation, observer disconnect, etc.) never ran. Now: walk byKey for entries missing from the new order set, fire lvt-destroyed on each subtree before the replaceChildren. 2. **Directive-touched signal complements nodes-added signal** The `nodesAddedThisRender > 0` skip-scan optimization missed the case where the server adds a directive attribute to an existing element via attribute morph (e.g. `lvt-fx:keydown` on a button that didn't have it). Without scanning, the listener never wires and the user has to know about `data-lvt-force-update` as the escape hatch — which isn't publicly documented. Now morphdom.onBeforeElUpdated checks toEl for any `lvt-*` attribute and sets `directiveTouchedThisRender = true`. The scan gate becomes `nodesAdded > 0 || directiveTouched`, preserving the delete-only fast path while wiring listeners on attribute-only morphs that introduce new directives. Cost: per onBeforeElUpdated call, scan toEl.attributes for any name starting with "lvt-" (4-char prefix check via charCodeAt to avoid String.startsWith allocation overhead). At 80k descendants that's ~10ms — well below the post-render scans we'd otherwise run unnecessarily. 3. **__lvtTargetedHits gated behind explicit init** Previously incremented `window.__lvtTargetedHits` unconditionally, which polluted the global object in production. Now: increments only when the property is already present on `window` — tests initialize it via `window.__lvtTargetedHits = 0` before measuring; production never sets it so the increment branch is skipped. 4. **itemLookup O(N) acknowledged with comment** (Claude #3, low pri) Linear scan is bounded; building a Map<key,item> per render would amortize multi-u-op renders but adds caching complexity. Documented the trade-off in the lookup callback; revisit if profiling shows it on the hot path. Tests: 514 unit pass (+1 new applyReorder lvt-destroyed test). TestLargeTable + TestLargeTable_DeleteLatency_10k green at 1798ms, targeted-apply hits=1. * fix: address round-4 bot review (#107) Three Claude follow-up findings on commit 02c1755: 1. **Unknown op type now triggers fallback** (forward-compat) The switch's `default:` case left `opOK = true`, so any future unrecognised op type was silently treated as a success and the targeted-skip marker would have left the live DOM stale. Now sets `opOK = false` (and warns), forcing a full morphdom rebuild. 2. **Atomic insert: applyAppend/Prepend/InsertAfter bail before any DOM mutation on partial render failure** Previously, if `renderAndParse` failed for the second of two items, the first was already inserted with `lvt-mounted` fired. The fallback then ran a full morphdom rebuild — adding the same row again and firing `lvt-mounted` a second time. Refactored all three insert paths to render into a scratch DocumentFragment first, only splicing into the live DOM (and firing onNodeAdded + lvt-mounted) when ALL items rendered successfully. Extracted the common path into `renderItemsAtomic`. 3. **staticsContainKeyAttribute boundary check** (precision) `s.includes('data-key=')` had two false-positive classes: longer attribute names like `data-keystone=`, and `data-key=` appearing in text/attribute values. Tightened to a regex requiring the attr name be preceded by whitespace or `<` and followed by `\s*=` — eliminates the longer-name class. Quoted-value class is acknowledged as a known limitation in a code comment (false positives are SAFE: they just trigger the full-rebuild fallback without corrupting state). (4 minor: __lvtTargetedHits Symbol-key — declined; the existence-check guard is sufficient for the test-only use case and Symbol keys significantly complicate test setup. Documented in the existing comment.) Tests: 519 unit pass (+5 new). TestLargeTable + DeleteLatency_10k green at 1733ms with targeted-apply hits=1. * chore: address round-5 cleanup nits (#107) Three small Claude follow-up items on commit c26c003: 1. **applyReorder JSDoc**: said dropped rows fire no lvt-destroyed, but commit 02c1755 already fixed that to fire them. Comment was stale; updated to describe the correct (current) behavior. 2. **renderItemsAtomic dead `container` parameter**: leftover from the inline-helper refactor in c26c003. The splice callback closes over its container, so renderItemsAtomic doesn't need the param. Removed from signature + all 3 call sites. 3. **Pre-compile staticsContainKeyAttribute regexes**: hoisted the per-attr RegExp construction to module-level constants (KEY_ATTR_REGEXES). Previously rebuilt 2 RegExps per static segment per call. At initial render that's ~hundreds of allocations; now amortized to 2 at module load. No behavior change. 519 unit tests pass. TestLargeTable_DeleteLatency_10k: 1534ms wall-clock with hits=1. * fix: address round-6 bot review (#107) Three Claude follow-up items on commit ff715fd: 1. **directiveTouchedThisRender now requires NEW lvt-* attribute** The previous check fired the flag whenever onBeforeElUpdated saw any lvt-* attribute on toEl. For high-frequency `u`-op renders on rows that ALREADY carry a directive (e.g. Todos rows with lvt-fx:animate persisting across renders), this triggered a wrapper-wide directive scan on every render — negating the scan-avoidance optimization. Now we only flag when the attribute is NEW: `toAttrs.includes(n) && !fromEl.hasAttribute(n)`. Catches the originally-intended case (server adds a new directive via attribute morph) without thrashing on stable directives. 2. **Comment on no-rollback safety in apply() failure path** When `allOpsSucceeded = false`, earlier ops that ran are NOT rolled back. The fallback rebuild from `treeRenderer.renderState()` produces the authoritative HTML and morphdom reconciles whatever partial state the live DOM is in. Documented in the early-return block. 3. **firstKnownKey now extracts from a/p item._k as a fallback** For batches containing only a/p ops on a cold cache (rare; canApplyTargeted normally warms it), apply would have failed container lookup. Now firstKnownKey samples the new items' `_k` so apply can find the container in the cold-cache case. (4 minor: TargetedRangeOp.ops discriminated union — declined; would touch the public type API. The existing `any[]` is permissive but the runtime switch + per-op return-bool already enforces correctness. Documented as a TODO in a future cleanup.) No behavior change for the LargeTable hot path. 519 unit tests pass; TestLargeTable_DeleteLatency_10k green at 1705ms with targeted-apply hits=1. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Problem
In certain execution contexts (e.g., during WebSocket reconnection or after multiple connection cycles), the
WebSocketglobal object's constants can becomeundefinedeven when the WebSocket connection is active.This causes readyState comparisons like
readyState === WebSocket.OPENto fail:readyState = 1(WebSocket is OPEN)WebSocket.OPEN = undefined1 === undefined→falseSolution
Use the numeric constant
1directly instead ofWebSocket.OPENfor readyState comparisons.Changes
livetemplate-client.ts: FixedisReady()andsend()methods (2 locations)transport/websocket.ts: Fixedsend()method (1 location)Testing
Impact
Fixes issue where operations would incorrectly fall back to HTTP in full test suites despite having an open WebSocket connection.