Skip to content

Comments

feat: v0.2.0 inbound/outbound architecture + 5 new features#13

Merged
jackmisner merged 4 commits intomainfrom
feature/persistent-storage-ttl
Feb 13, 2026
Merged

feat: v0.2.0 inbound/outbound architecture + 5 new features#13
jackmisner merged 4 commits intomainfrom
feature/persistent-storage-ttl

Conversation

@jackmisner
Copy link
Owner

@jackmisner jackmisner commented Feb 13, 2026

Summary

🤖 Generated with Nori

  • Directory restructure: Moves src/core/ into src/inbound/, src/outbound/, src/common/ — zero public API changes
  • Event callbacks: Adds onCapture, onStore, onClear, onAppend, onExpire lifecycle hooks to config and core function options
  • First-touch / last-touch attribution: Stores first-touch (write-once), last-touch (always updates), or both, with configurable storage key suffixes
  • UTM link builder: buildUtmUrl() and validateUtmValues() for constructing UTM-tagged URLs with validation and warnings
  • Form field population: Vanilla JS (populateFormFields, createUtmHiddenFields) and React (UtmHiddenFields, useUtmFormData) for injecting UTM data into forms
  • Automatic link decoration: Vanilla JS (decorateLinks, observeAndDecorateLinks) and React (UtmLinkDecorator, useUtmLinkDecorator) for auto-appending UTM params to page links
  • README updated with full documentation for all new features

455 tests passing. No breaking changes — all new features are additive with backward-compatible defaults.

Test Plan

  • All 455 tests pass (npm test)
  • Type check clean (npm run type-check)
  • Lint clean (npm run lint)
  • Build succeeds (npm run build)
  • Public API exports unchanged from v0.1.3 (new exports added, none removed)
  • Attribution mode: 'last' (default) behaves identically to previous storage behavior
  • Event callbacks are optional and wrapped in try-catch

Share Nori with your team: https://www.npmjs.com/package/nori-ai

Summary by CodeRabbit

  • New Features

    • Multi-mode attribution (first/last/both) with default attribution config and exported attribution types.
    • Event lifecycle callbacks: onCapture, onStore, onClear, onAppend, onExpire.
    • Outbound: URL builder with validation/normalisation and onAppend; automatic link decorator with observe/cleanup.
    • Form helpers: populate/create hidden UTM fields; React: UtmHiddenFields, UtmLinkDecorator, useUtmFormData, useUtmLinkDecorator; tracking hook exposes firstTouchParams/lastTouchParams.
    • Storage: enhanced clear API accepting options and new clear-related type; storage onStore/onExpire hooks.
  • Documentation

    • Expanded docs for inbound/outbound/common, attribution, callbacks and types.
  • Tests

    • Extensive tests covering attribution, callbacks, form, builder, decorator and React integrations.

jackmisner and others added 2 commits February 13, 2026 21:35
…rms, link decoration, and event callbacks

Restructure src/core/ into src/inbound/, src/outbound/, src/common/ directories.
Add 5 new features: event lifecycle callbacks, first-touch/last-touch attribution,
UTM link builder with validation, form field population (vanilla JS + React),
and automatic link decoration (vanilla JS + React). Update README with full
documentation for all new features.

🤖 Generated with [Nori](https://nori.ai)
🤖 Generated with [Nori](https://nori.ai)

Co-Authored-By: Nori <contact@tilework.tech>
@coderabbitai
Copy link

coderabbitai bot commented Feb 13, 2026

Walkthrough

Reorganises exports into inbound/outbound/common, adds multi-mode attribution (first/last/both) with configurable suffixes, introduces lifecycle callbacks (onCapture, onStore, onClear, onAppend, onExpire), expands React surface (hidden fields, link decorator, hooks), updates storage API (ClearOptions overload) and broadens public types and outbound builder/decorator features.

Changes

Cohort / File(s) Summary
Module reorganisation & barrels
src/index.ts, src/common/index.ts, src/inbound/index.ts, src/outbound/index.ts, src/react/index.ts
Rehome and re-export utilities into inbound/outbound/common groups; adjust public barrel exports to expose inbound/outbound surfaces, move some previously-common exports to inbound/outbound, and add ClearOptions to common exports.
Attribution core
src/inbound/attribution.ts, src/config/defaults.ts, src/config/loader.ts, src/types/index.ts, __tests__/inbound/attribution.test.ts
New inbound attribution module and types (AttributionMode, TouchType, AttributionConfig); storeWithAttribution/getAttributedParams implement first/last/both semantics with configurable suffixes and onStore callback; defaults and config merging updated.
Storage & lifecycle events
src/common/storage.ts, src/types/index.ts, __tests__/common/events.test.ts
Storage API extended: StorageOptions accepts onStore/onExpire; getStoredUtmParameters invokes onExpire when TTL expiry is cleaned; added exported ClearOptions; clearStoredUtmParameters supports options-object overload and optional onClear; callbacks wrapped in try/catch.
Inbound capture & sanitisation exports
src/inbound/capture.ts, src/inbound/sanitizer.ts, src/inbound/pii-filter.ts, src/common/*
Capture now accepts onCapture and returns normalized keyFormat (camelCase when requested); sanitizer/PII utilities re-exported under inbound and removed from common barrel to reflect module split.
Form population & React form helpers
src/inbound/form.ts, src/react/UtmHiddenFields.tsx, src/react/useUtmFormData.ts, __tests__/inbound/form.test.ts, __tests__/react/UtmHiddenFields.test.tsx
New populateFormFields/createUtmHiddenFields with strategies (name, data-attribute, auto-create); React UtmHiddenFields component and useUtmFormData hook return flat form-ready UTM data.
Outbound builder & validation
src/outbound/builder.ts, src/types/index.ts, __tests__/outbound/builder.test.ts
New buildUtmUrl API plus validateUtmValues, BuildResult structure, normalization/validation options, lowercase transform and optional onAppend hook; returns structured errors/warnings.
Appender & decorator enhancements
src/outbound/appender.ts, src/outbound/decorator.ts, src/react/UtmLinkDecorator.tsx, __tests__/outbound/decorator.test.ts, __tests__/react/UtmLinkDecorator.test.tsx
appendUtmParameters gains optional onAppend callback; new decorateLinks and observeAndDecorateLinks with host/include/exclude/skipExisting controls and MutationObserver support; React useUtmLinkDecorator/UtmLinkDecorator for scoped decoration.
React tracking hook updates
src/react/useUtmTracking.ts
useUtmTracking return shape extended to include firstTouchParams and lastTouchParams; capture/store/clear/append flows updated to use attribution-enabled storage and config callbacks; clear supports clearing attribution-suffixed keys according to mode.
Public types and config
src/types/index.ts, src/config/defaults.ts, src/config/docs.md
New/expanded types: AttributionMode, TouchType, AttributionConfig; AppendOptions includes onAppend; UtmConfig/ResolvedUtmConfig include attribution and lifecycle callbacks; DEFAULT_ATTRIBUTION_CONFIG added and merged into defaults.
Tests & docs alignment
__tests__/**/*, src/*.md, src/common/docs.md, src/inbound/docs.md, src/outbound/docs.md, src/react/docs.md, src/docs.md
Extensive new/updated tests for attribution, lifecycle events, form population, outbound builder/decorator; documentation added/rewritten for inbound/outbound/common modules, updated notes on envelope storage, TTL semantics and safe callback handling.
Refactors & import path updates
various __tests__/*, src/debug/index.ts, removed src/core/docs.md
Numerous import path updates replacing core/* with inbound/*/outbound/*/common/*; core docs removed and module-focused docs introduced.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main architectural change (inbound/outbound restructuring) and the five key features being added (attribution, callbacks, form integration, link decoration, utilities).
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
src/inbound/form.ts (1)

49-59: Consider adding exhaustiveness check for consistency.

The PR mentions adding an exhaustiveness (never) check in the attribution switch. For consistency and defensive programming, consider adding the same pattern here.

♻️ Suggested change
     case 'auto-create':
       count = autoCreateFields(forms, params)
       break
+    default: {
+      const _exhaustive: never = strategy
+      throw new Error(`Unknown strategy: ${_exhaustive}`)
+    }
   }
src/outbound/decorator.ts (1)

127-136: Consider debouncing rapid mutations for performance.

In SPAs with frequent DOM updates, calling decorateLinks on every mutation could cause repeated full-page link scans. A requestAnimationFrame or debounce pattern would batch rapid mutations into a single decoration pass.

♻️ Optional: batch mutations with requestAnimationFrame
   let decorating = false
+  let pending = false
   const observer = new MutationObserver(() => {
-    // Prevent re-entrant decoration (our own href mutations trigger MutationObserver)
     if (decorating) return
-    decorating = true
-    try {
-      decorateLinks(options)
-    } finally {
-      decorating = false
-    }
+    if (pending) return
+    pending = true
+    requestAnimationFrame(() => {
+      pending = false
+      decorating = true
+      try {
+        decorateLinks(options)
+      } finally {
+        decorating = false
+      }
+    })
   })
src/react/useUtmTracking.ts (1)

254-280: Consider passing onExpire callback to attribution storage reads.

The getStoredUtmParameters calls for firstTouchParams and lastTouchParams don't include the onExpire callback from config. If stored data expires during these reads, the callback won't fire, potentially missing analytics or cleanup triggers the user configured.

If this is intentional (to only fire onExpire during explicit operations), a brief comment would clarify the design choice.

♻️ Suggested change to pass onExpire
   const firstTouchParams = useMemo(
     () =>
       attributionMode === 'last'
         ? null
         : getStoredUtmParameters({
             storageKey: config.storageKey + (config.attribution?.firstTouchSuffix ?? '_first'),
             keyFormat: config.keyFormat,
             storageType: config.storageType,
+            onExpire: config.onExpire,
           }),
     // Re-read when utmParameters changes (i.e., after capture/store)
     // eslint-disable-next-line react-hooks/exhaustive-deps
     [attributionMode, config.storageKey, config.keyFormat, config.storageType, utmParameters],
   )
   const lastTouchParams = useMemo(
     () =>
       attributionMode === 'first'
         ? null
         : attributionMode === 'both'
           ? getStoredUtmParameters({
               storageKey: config.storageKey + (config.attribution?.lastTouchSuffix ?? '_last'),
               keyFormat: config.keyFormat,
               storageType: config.storageType,
+              onExpire: config.onExpire,
             })
           : utmParameters,

Comment @coderabbitai help to get the list of available commands and usage tips.

…nt duplicate hidden inputs and observer loops

- Hook now calls storeWithAttribution() instead of storeUtmParameters() so
  attribution mode (first/last/both) actually works through useUtmTracking
- Forward onCapture, onStore, onClear, onAppend callbacks from config to
  the underlying core functions
- Wrap firstTouchParams/lastTouchParams in useMemo to avoid re-reading
  storage on every render
- autoCreateFields now checks for existing hidden inputs before creating
  duplicates
- observeAndDecorateLinks uses a re-entrancy guard to prevent infinite
  loops caused by its own href mutations

🤖 Generated with [Nori](https://nori.ai)
🤖 Generated with [Nori](https://nori.ai)

Co-Authored-By: Nori <contact@tilework.tech>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
__tests__/common/validator.test.ts (1)

1-12: ⚠️ Potential issue | 🟠 Major

Restore defaultProtocol after each test.

setDefaultProtocol() mutates module state; without restoration, later suites can inherit the modified value and behave differently depending on run order.

Proposed fix
-import { describe, it, expect, beforeEach } from 'vitest'
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
@@
 describe('getDefaultProtocol / setDefaultProtocol', () => {
+  let originalProtocol: string
+
   beforeEach(() => {
+    originalProtocol = getDefaultProtocol()
     setDefaultProtocol('https://')
   })
+
+  afterEach(() => {
+    setDefaultProtocol(originalProtocol)
+  })
Based on learnings: In `validator.ts`, manage module-level mutable state `defaultProtocol` carefully — tests that call `setDefaultProtocol()` must restore the original value.

Also applies to: 171-192

🤖 Fix all issues with AI agents
In `@src/inbound/form.ts`:
- Around line 140-154: The autoCreateFields function currently appends hidden
inputs blindly which can create duplicates; update it to, for each form (forms)
and each param key in UtmParameters, first check the form for existing inputs
with the same name (e.g., query the form for input[name=key] or
input[name="${key}"]) and if one exists update its value instead of appending a
new element, otherwise create and append the hidden input; ensure the increment
of count only happens when a new input is created (not when updating an existing
input) and keep references to the function name autoCreateFields, the forms
variable, and the input creation branch to locate the change.
- Around line 11-25: FormPopulateOptions declares an unused touch property
causing confusion; either remove touch from the interface or wire it into the
form population logic. Locate the FormPopulateOptions interface and either
delete the touch?: TouchType declaration, or update populateFormFields and
createUtmHiddenFields to accept/use options.touch when selecting which Touch
(e.g., first/last) to read from storageKey/storageType; ensure any references to
TouchType are updated and unit tests adjusted accordingly.

In `@src/outbound/decorator.ts`:
- Around line 116-131: The function observeAndDecorateLinks can throw if
document.body is not present; before calling observer.observe(document.body,
...) guard with a null check (if (!document.body) return () => {}) so the
function becomes a no-op when run in <head> or before DOM ready, and ensure you
return a cleanup function that calls observer.disconnect() when observation is
started; reference observeAndDecorateLinks, decorateLinks, MutationObserver,
observer.observe and observer.disconnect to locate the changes.
- Around line 12-58: The public option touch in LinkDecoratorOptions is declared
but unused; update decorateLinks to destructure touch from options and pass it
into getStoredUtmParameters so callers can select first/last touch, or remove
the option entirely; specifically, add touch?: TouchType to the destructuring in
decorateLinks (alongside storageKey/storageType) and call
getStoredUtmParameters({ storageKey, storageType, touch }) (or update that
helper's signature to accept a touch arg), and ensure any types (TouchType and
getStoredUtmParameters param type) are updated accordingly.
🧹 Nitpick comments (13)
src/outbound/builder.ts (1)

12-12: Consider documenting the UNSAFE_CHARS constant.

The regex /[&=?#]/ captures characters that would interfere with URL parsing. A brief inline comment explaining why these specific characters are unsafe for UTM values would improve maintainability.

📝 Proposed documentation
-const UNSAFE_CHARS = /[&=?#]/
+/** Characters that break URL query string parsing when used in UTM values */
+const UNSAFE_CHARS = /[&=?#]/
__tests__/outbound/appender.test.ts (1)

1-6: Import path correctly updated; consider adding tests for onAppend callback.

The import path update aligns with the module restructure. However, the new onAppend callback feature in appendUtmParameters lacks test coverage in this file. Consider adding tests to verify:

  • Callback is invoked with correct URL and params
  • Callback errors don't break the append operation
🧪 Suggested test cases
describe('onAppend callback', () => {
  it('invokes onAppend with result URL and params', () => {
    const onAppend = vi.fn()
    appendUtmParameters(
      'https://example.com',
      { utm_source: 'test' },
      { onAppend },
    )
    expect(onAppend).toHaveBeenCalledWith(
      'https://example.com/?utm_source=test',
      { utm_source: 'test' },
    )
  })

  it('does not throw when onAppend throws', () => {
    const onAppend = vi.fn(() => { throw new Error('callback error') })
    expect(() =>
      appendUtmParameters('https://example.com', { utm_source: 'test' }, { onAppend }),
    ).not.toThrow()
  })
})
__tests__/react/UtmLinkDecorator.test.tsx (1)

42-47: Consider removing unnecessary storeUtmParameters call and expanding hook test.

Line 43 stores UTM parameters, but this test only verifies the ref object structure—the stored params aren't used. Additionally, the hook test could be more comprehensive by verifying actual decoration behaviour when the ref is attached to a container.

🧪 Suggested improvements
 describe('useUtmLinkDecorator', () => {
   beforeEach(() => {
     sessionStorage.clear()
     document.body.replaceChildren()
   })

   it('returns a ref object', () => {
-    storeUtmParameters({ utm_source: 'google' })
     const { result } = renderHook(() => useUtmLinkDecorator())
     expect(result.current).toBeDefined()
     expect(result.current).toHaveProperty('current')
   })
+
+  it('decorates links when ref is attached to container with stored params', () => {
+    storeUtmParameters({ utm_source: 'test' })
+    function TestComponent() {
+      const ref = useUtmLinkDecorator()
+      return (
+        <div ref={ref}>
+          <a href="https://example.com">Link</a>
+        </div>
+      )
+    }
+    const { container } = render(<TestComponent />)
+    const link = container.querySelector('a') as HTMLAnchorElement
+    expect(link.href).toContain('utm_source=test')
+  })
 })
src/react/UtmHiddenFields.tsx (1)

12-21: Unused touch prop in interface and component.

The touch prop is declared in UtmHiddenFieldsProps but is never destructured or used in the component logic. This may be an incomplete feature or dead code.

♻️ Either remove the unused prop or implement the intended behaviour

If touch is not needed:

 export interface UtmHiddenFieldsProps {
-  touch?: TouchType
   keyFormat?: KeyFormat
   prefix?: string
   storageKey?: string
   storageType?: 'session' | 'local'
 }

If touch should influence which storage key is read (e.g., for first-touch vs last-touch attribution), consider passing it to getStoredUtmParameters or deriving the storage key from it.

src/react/useUtmFormData.ts (1)

11-19: Unused touch option in interface and hook.

Similar to UtmHiddenFields, the touch option is declared in UseUtmFormDataOptions but never used in the hook logic. This creates a misleading API where consumers might expect the option to affect behaviour.

♻️ Either remove the unused option or implement the intended behaviour

If touch is not needed:

 export interface UseUtmFormDataOptions {
-  touch?: TouchType
   storageKey?: string
   storageType?: 'session' | 'local'
   keyFormat?: 'snake_case' | 'camelCase'
 }

If touch should select first-touch vs last-touch data, you would need to derive the storage key or pass it through to the storage call.

__tests__/outbound/decorator.test.ts (1)

131-151: LGTM with optional enhancement suggestion.

The test suite provides good coverage for decorateLinks including host filtering, skipExisting behaviour, and callbacks. The observeAndDecorateLinks tests verify the essential contract (cleanup function and immediate decoration).

💡 Consider adding a test for MutationObserver behaviour

The current tests don't verify that dynamically added links are decorated after the initial call. This would require triggering the MutationObserver:

it('decorates links added after initial call', async () => {
  storeUtmParameters({ utm_source: 'google' })
  const cleanup = observeAndDecorateLinks()
  
  // Add link after observer is set up
  const newLink = createLink('https://example.com/new-page')
  
  // MutationObserver is async, wait for it
  await new Promise(resolve => setTimeout(resolve, 0))
  
  expect(newLink.href).toContain('utm_source=google')
  cleanup()
})

This is optional since verifying MutationObserver behaviour in jsdom can be brittle.

src/react/useUtmTracking.ts (1)

235-254: Consider memoising attribution parameter retrieval to avoid redundant storage reads.

The firstTouchParams and lastTouchParams values are computed synchronously during every render by calling getStoredUtmParameters. Since these depend only on stable config values (config.storageKey, config.attribution, config.keyFormat, config.storageType), they will produce identical results across re-renders unless storage is externally modified.

For components that re-render frequently, this could result in unnecessary JSON.parse calls on each render.

💡 Proposed optimisation using useMemo
+  // Attribution mode determines which touch params are available
+  const attributionMode = config.attribution?.mode ?? 'last'
+
+  const { firstTouchParams, lastTouchParams } = useMemo(() => {
+    const first =
+      attributionMode === 'last'
+        ? null
+        : getStoredUtmParameters({
+            storageKey: config.storageKey + (config.attribution?.firstTouchSuffix ?? '_first'),
+            keyFormat: config.keyFormat,
+            storageType: config.storageType,
+          })
+    const last =
+      attributionMode === 'first'
+        ? null
+        : attributionMode === 'both'
+          ? getStoredUtmParameters({
+              storageKey: config.storageKey + (config.attribution?.lastTouchSuffix ?? '_last'),
+              keyFormat: config.keyFormat,
+              storageType: config.storageType,
+            })
+          : utmParameters
+    return { firstTouchParams: first, lastTouchParams: last }
+  }, [
+    attributionMode,
+    config.storageKey,
+    config.attribution?.firstTouchSuffix,
+    config.attribution?.lastTouchSuffix,
+    config.keyFormat,
+    config.storageType,
+    utmParameters,
+  ])
-  // Attribution mode determines which touch params are available
-  const attributionMode = config.attribution?.mode ?? 'last'
-  const firstTouchParams =
-    attributionMode === 'last'
-      ? null
-      : getStoredUtmParameters({
-          storageKey: config.storageKey + (config.attribution?.firstTouchSuffix ?? '_first'),
-          keyFormat: config.keyFormat,
-          storageType: config.storageType,
-        })
-  const lastTouchParams =
-    attributionMode === 'first'
-      ? null
-      : attributionMode === 'both'
-        ? getStoredUtmParameters({
-            storageKey: config.storageKey + (config.attribution?.lastTouchSuffix ?? '_last'),
-            keyFormat: config.keyFormat,
-            storageType: config.storageType,
-          })
-        : utmParameters
src/config/loader.ts (1)

241-384: Consider adding validation for attribution config and event callbacks.

The validateConfig function validates sanitize and piiFiltering nested configs but doesn't validate the new attribution config (mode, firstTouchSuffix, lastTouchSuffix) or event callbacks (onCapture, onStore, etc.). For consistency, consider adding validation for:

  • attribution.mode must be 'last' | 'first' | 'both'
  • attribution.firstTouchSuffix and lastTouchSuffix must be strings if provided
  • Event callbacks must be functions if provided
♻️ Proposed validation additions
   if (c.piiFiltering !== undefined) {
     // ... existing piiFiltering validation ...
   }
+
+  if (c.attribution !== undefined) {
+    if (typeof c.attribution !== 'object' || c.attribution === null || Array.isArray(c.attribution)) {
+      errors.push('attribution must be an object')
+    } else {
+      const a = c.attribution as Record<string, unknown>
+      if (a.mode !== undefined && a.mode !== 'last' && a.mode !== 'first' && a.mode !== 'both') {
+        errors.push('attribution.mode must be "last", "first", or "both"')
+      }
+      if (a.firstTouchSuffix !== undefined && typeof a.firstTouchSuffix !== 'string') {
+        errors.push('attribution.firstTouchSuffix must be a string')
+      }
+      if (a.lastTouchSuffix !== undefined && typeof a.lastTouchSuffix !== 'string') {
+        errors.push('attribution.lastTouchSuffix must be a string')
+      }
+    }
+  }
+
+  const callbackFields = ['onCapture', 'onStore', 'onClear', 'onAppend', 'onExpire'] as const
+  for (const field of callbackFields) {
+    if (c[field] !== undefined && typeof c[field] !== 'function') {
+      errors.push(`${field} must be a function`)
+    }
+  }

   return errors
 }
src/react/UtmLinkDecorator.tsx (2)

20-39: Missing options in useEffect dependency array.

The useEffect references options and options.selector but has an empty dependency array. While the comment indicates decoration runs "on mount", this will cause eslint's react-hooks/exhaustive-deps rule to warn. If mount-only behaviour is intentional, consider either:

  1. Suppressing the lint rule with a comment explaining the intent
  2. Using useRef to capture options at mount time

Additionally, the links query on line 25 is only used as a guard — the actual decoration re-queries via decorateLinks. This is fine but slightly redundant.

♻️ Option 1: Add lint suppression with explanation
   useEffect(() => {
     if (!ref.current) return

     // Scope decoration to this container
     const container = ref.current
     const links = container.querySelectorAll<HTMLAnchorElement>(options.selector ?? 'a[href]')
     if (links.length === 0) return

     // Use decorateLinks with a scoped selector is tricky since it queries document.
     // Instead, manually scope: temporarily add a unique attribute and use it as selector.
     const scopeId = `__utm_decorator_${Date.now()}`
     container.setAttribute('data-utm-scope', scopeId)

     decorateLinks({
       ...options,
       selector: `[data-utm-scope="${scopeId}"] ${options.selector ?? 'a[href]'}`,
     })

     container.removeAttribute('data-utm-scope')
+    // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally run only on mount
   }, [])

51-56: Redundant type cast on ref.

The cast ref as React.RefObject<HTMLDivElement> is unnecessary since ref is already typed as React.RefObject<HTMLDivElement | null>, which is assignable to the ref prop expecting React.RefObject<HTMLDivElement>.

♻️ Proposed simplification
 export function UtmLinkDecorator(props: UtmLinkDecoratorProps): React.ReactElement {
   const { children, ...options } = props
   const ref = useUtmLinkDecorator(options)

-  return <div ref={ref as React.RefObject<HTMLDivElement>}>{children}</div>
+  return <div ref={ref}>{children}</div>
 }
src/inbound/form.ts (1)

111-121: Minor: Clarify the camelCase key stripping logic.

The double replace key.replace(/^utm_/, '').replace(/^utm/, '') handles both snake_case (utm_sourcesource) and camelCase (utmSourceSource). The subsequent .toLowerCase() normalises the result, but this could be simplified with a single regex.

♻️ Proposed simplification
   for (const [key, value] of Object.entries(params)) {
     if (value === undefined) continue
-    // Strip utm_ prefix to get short name
-    const shortName = key.replace(/^utm_/, '').replace(/^utm/, '')
-    if (shortName) {
-      // Handle snake_case: utm_source → source
-      shortNameMap.set(shortName.toLowerCase(), value)
-    }
+    // Strip utm_ or utm prefix (handles both snake_case and camelCase)
+    const shortName = key.replace(/^utm_?/i, '').toLowerCase()
+    if (shortName) {
+      shortNameMap.set(shortName, value)
+    }
   }
README.md (1)

189-202: Clarify legacy signature deprecation status.

The documentation shows both the new options object API and legacy positional arguments for clearStoredUtmParameters. Consider adding a note indicating whether the legacy signatures are deprecated or will be maintained long-term, to help users understand which pattern to prefer.

src/inbound/attribution.ts (1)

52-108: Consider adding a default case for defensive programming.

The switch statement handles all current AttributionMode values ('last', 'first', 'both'), but lacks a default case. If a new mode is added to the type without updating this function, it would silently do nothing.

🛡️ Proposed fix to add exhaustiveness check
     case 'both': {
       // First-touch: write-once
       const hasFirstTouch = hasStoredUtmParameters(firstKey, storageType)
       if (!hasFirstTouch) {
         storeUtmParameters(params, { ...baseStorageOpts, storageKey: firstKey })
         if (onStore) {
           try {
             onStore(params, { storageType, touch: 'first' })
           } catch {
             // Callbacks must not break the pipeline
           }
         }
       }
       // Last-touch: always update
       storeUtmParameters(params, { ...baseStorageOpts, storageKey: lastKey })
       if (onStore) {
         try {
           onStore(params, { storageType, touch: 'last' })
         } catch {
           // Callbacks must not break the pipeline
         }
       }
       // Also write to main key (same as last-touch)
       storeUtmParameters(params, { ...baseStorageOpts, storageKey })
       break
     }
+
+    default: {
+      // Exhaustiveness check - ensures all modes are handled
+      const _exhaustiveCheck: never = attribution.mode
+      throw new Error(`Unknown attribution mode: ${_exhaustiveCheck}`)
+    }
   }
 }

Comment on lines 140 to 154
function autoCreateFields(forms: NodeListOf<Element>, params: UtmParameters): number {
let count = 0
for (const form of forms) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue
const input = document.createElement('input')
input.type = 'hidden'
input.name = key
input.value = value
form.appendChild(input)
count++
}
}
return count
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

autoCreateFields may create duplicate hidden inputs.

The function appends new hidden inputs without checking if fields with the same name already exist in the form. If called multiple times (e.g., on re-render or multiple invocations), this could result in duplicate form fields being submitted.

🛡️ Proposed fix to prevent duplicates
 function autoCreateFields(forms: NodeListOf<Element>, params: UtmParameters): number {
   let count = 0
   for (const form of forms) {
     for (const [key, value] of Object.entries(params)) {
       if (value === undefined) continue
+      // Skip if field already exists
+      const existing = form.querySelector(`input[name="${key}"]`)
+      if (existing) continue
       const input = document.createElement('input')
       input.type = 'hidden'
       input.name = key
       input.value = value
       form.appendChild(input)
       count++
     }
   }
   return count
 }
🤖 Prompt for AI Agents
In `@src/inbound/form.ts` around lines 140 - 154, The autoCreateFields function
currently appends hidden inputs blindly which can create duplicates; update it
to, for each form (forms) and each param key in UtmParameters, first check the
form for existing inputs with the same name (e.g., query the form for
input[name=key] or input[name="${key}"]) and if one exists update its value
instead of appending a new element, otherwise create and append the hidden
input; ensure the increment of count only happens when a new input is created
(not when updating an existing input) and keep references to the function name
autoCreateFields, the forms variable, and the input creation branch to locate
the change.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/inbound/form.ts`:
- Around line 66-82: The JSDoc for createUtmHiddenFields is inaccurate:
autoCreateFields counts both newly created and updated inputs, so update the
docstring to say it "Returns count of fields populated" (or similar) to reflect
that it includes updated fields, and ensure the comment above the function
references the same behavior; locate the createUtmHiddenFields function and
replace "Returns count of fields created" with "Returns count of fields
populated" (mentioning autoCreateFields) so docs match implementation.

In `@src/react/useUtmTracking.ts`:
- Around line 156-163: The clear() callback only removes the base storageKey but
not the attribution-specific keys, leaving firstTouchParams/lastTouchParams
stale; update clear() (and the clearStoredUtmParameters call it invokes) to also
remove storageKey + '_first' and storageKey + '_last' (and any other attribution
suffixes used by storeWithAttribution) so all attribution-related entries are
cleared, then call setUtmParameters(null) as before; reference the clear()
function, clearStoredUtmParameters helper, storeWithAttribution behavior, and
the storageKey/first/last suffixes to locate where to add removal of those extra
keys.
🧹 Nitpick comments (2)
src/react/useUtmTracking.ts (1)

241-269: Attribution mode logic is correct; consider passing onExpire for consistency.

The conditional logic for firstTouchParams and lastTouchParams correctly handles all three attribution modes. The use of utmParameters as a dependency to force re-reads after capture is a valid pattern.

Minor observation: getStoredUtmParameters accepts an onExpire callback (per the relevant snippet in src/common/storage.ts), but it's not passed here. If first-touch or last-touch data expires during retrieval, the configured config.onExpire callback won't be triggered. Consider whether this should be consistent with other storage reads.

src/inbound/form.ts (1)

113-121: Consider simplifying the prefix-stripping logic.

The double replace handles both utm_source (snake_case) and utmSource (camelCase), which works correctly. A single regex could make the intent clearer, though this is a minor stylistic preference.

♻️ Optional: unified regex
     // Strip utm_ prefix to get short name
-    const shortName = key.replace(/^utm_/, '').replace(/^utm/, '')
+    const shortName = key.replace(/^utm_?/i, '')

Note: Using the i flag makes it case-insensitive, handling both formats in one pass.

…ed touch props, add guards

- clear() in useUtmTracking now also removes first-touch and last-touch
  suffixed storage keys so attribution data doesn't go stale
- Remove unused `touch` prop from FormPopulateOptions, LinkDecoratorOptions,
  UtmHiddenFieldsProps, and UseUtmFormDataOptions (dead code)
- Add document.body null guard in observeAndDecorateLinks
- Add exhaustiveness check (never) in attribution switch statement
- Fix createUtmHiddenFields JSDoc to reflect update-or-create behavior

🤖 Generated with [Nori](https://nori.ai)
🤖 Generated with [Nori](https://nori.ai)

Co-Authored-By: Nori <contact@tilework.tech>
@jackmisner jackmisner merged commit 6eb8b31 into main Feb 13, 2026
5 checks passed
@jackmisner jackmisner deleted the feature/persistent-storage-ttl branch February 13, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant