') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Build useETagCache's config object once per hook instance, not once per render by os-sam · Pull Request #6885 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/6817-usetagcache-config-alloc.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
---
'@object-ui/react': patch
---

`useETagCache` builds its config object once per hook instance instead of once
per render (objectui#6817).

`useRef({ enabled, storage, storagePrefix, maxEntries, ttl })` evaluated that
literal on **every** render and kept only the first result, so every later
render allocated a five-key object that was discarded. It now comes from a
`useMemo` keyed on the five values, which is also what the ref's
`useInsertionEffect` write publishes.

`patch`, not `minor`: nothing a published consumer can observe changes. The
public shape, the returned callbacks' identities and the values the stable
`[]`-deps callbacks read off the ref are all unchanged — the object's identity
is private to the hook, so the only difference is the allocation that no longer
happens. Same pattern PR objectui#6796 repaired in `useSchemaPersistence`; this
is the half of that class the `react-hooks/refs` rule structurally cannot see,
which is why it needed a test rather than a lint fix.
134 changes: 134 additions & 0 deletions packages/react/src/hooks/__tests__/useETagCache.configAlloc.test.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
/**
* ObjectUI — useETagCache config-object allocation pin (objectui#6817)
* Copyright (c) 2024-present ObjectStack Inc.
*
* `useETagCache` seeded its config ref with an inline object literal:
*
* const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
*
* `useRef` evaluates that argument on EVERY render and keeps only the first
* result, so every later render allocated a five-key object that was thrown
* away. It is the same shape PR objectui#6796 repaired in `useSchemaPersistence`
* (`useRef(createLocalStorageAdapter())`), with one difference that is the whole
* reason this needed its own card: clearing that one's `react-hooks/refs`
* warning REQUIRED the change, and clearing this one's did not — so the lint
* rule structurally cannot see this half of the class, and only a test can.
*
* ## Why this file mocks `useRef`
*
* The config object is private to the hook: nothing exports it, no in-repo
* consumer renders the hook (`packages/react/src/hooks/index.ts` exports it and
* nothing else calls it), and its identity is deliberately invisible from the
* outside. So "an object was allocated and discarded" has exactly one external
* observation point — the value handed to `useRef` on each render. The wrapper
* below records that argument and delegates to the real `useRef`, so the hook
* still runs on genuine React.
*
* The pins come in pairs on purpose. Pin 1 fails on the old code (three renders
* hand `useRef` three different objects). Pins 2a-2e pass on BOTH the old and
* the new code, and exist to stop the over-fix: memoizing on `[]` would make
* pin 1 pass while freezing the config at its first render — a semantics break
* the timing pins in `useETagCache.configTiming.test.tsx` would then catch, but
* these say it in the same file as the claim they qualify.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useETagCache, type ETagCacheConfig } from '../useETagCache';

const { seeds } = vi.hoisted(() => ({ seeds: [] as unknown[] }));

vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>();
const useRef = (initialValue: unknown) => {
seeds.push(initialValue);
return (actual.useRef as (v: unknown) => unknown)(initialValue);
};
return { ...actual, useRef } as unknown as typeof import('react');
});

/** The five keys the hook keeps in its config ref, per useETagCache.ts. */
const CONFIG_KEYS = ['enabled', 'storage', 'storagePrefix', 'maxEntries', 'ttl'] as const;

type ConfigSeed = Record<(typeof CONFIG_KEYS)[number], unknown>;

/**
* Every `useRef` seed recorded so far that has the config shape. The filter
* matters: the recorder sees every `useRef` call in the rendered tree, not just
* this hook's, so an unrelated ref elsewhere must not be counted as a config
* allocation (and must not be able to mask one either).
*/
function configSeeds(): ConfigSeed[] {
return seeds.filter(
(value): value is ConfigSeed =>
typeof value === 'object' &&
value !== null &&
CONFIG_KEYS.every((key) => key in (value as Record<string, unknown>)),
);
}

beforeEach(() => {
seeds.length = 0;
});

describe('useETagCache — the config object is built once per hook instance (#6817)', () => {
// ---- pin 1: THE DEFECT — red before the repair, green after ---------------
it('hands useRef the same config object on every render when nothing changed', () => {
const { rerender } = renderHook(
({ ttl }: { ttl: number }) => useETagCache({ ttl, storagePrefix: 'alloc-pin' }),
{ initialProps: { ttl: 1_000 } },
);

rerender({ ttl: 1_000 });
rerender({ ttl: 1_000 });

const seen = configSeeds();
// Three renders must have reached the ref seed, or this pin is measuring
// nothing and the identity assertion below would pass vacuously.
expect(seen.length).toBe(3);
expect(seen[1]).toBe(seen[0]);
expect(seen[2]).toBe(seen[0]);
expect(new Set(seen).size).toBe(1);
});

// ---- pin 2: NOT an over-fix — a real config change still allocates --------
// `useMemo(..., [])` would satisfy pin 1 and freeze the config at its first
// render. One case per key, so a dependency list missing any single one of
// the five fails here rather than in a consumer.
const changes: Array<{ key: string; before: ETagCacheConfig; after: ETagCacheConfig }> = [
{ key: 'enabled', before: { enabled: true }, after: { enabled: false } },
{ key: 'storage', before: { storage: 'memory' }, after: { storage: 'localStorage' } },
{ key: 'storagePrefix', before: { storagePrefix: 'p-a' }, after: { storagePrefix: 'p-b' } },
{ key: 'maxEntries', before: { maxEntries: 10 }, after: { maxEntries: 20 } },
{ key: 'ttl', before: { ttl: 1_000 }, after: { ttl: 2_000 } },
];

for (const { key, before, after } of changes) {
it(`builds a fresh config carrying the new value when \`${key}\` changes`, () => {
const { rerender } = renderHook(
({ config }: { config: ETagCacheConfig }) => useETagCache(config),
{ initialProps: { config: before } },
);

rerender({ config: after });

const seen = configSeeds();
expect(seen.length).toBe(2);
expect(seen[1]).not.toBe(seen[0]);
expect(seen[0][key as keyof ConfigSeed]).toBe(before[key as keyof ETagCacheConfig]);
expect(seen[1][key as keyof ConfigSeed]).toBe(after[key as keyof ETagCacheConfig]);
});
}

// ---- pin 3: the recorder is real ----------------------------------------
// If the `vi.mock` above ever stopped intercepting, every pin here would go
// vacuously green on an empty `seeds` array except for the length assertions.
// This states the same guarantee once, directly.
it('records a config seed at all (the useRef interception is live)', () => {
renderHook(() => useETagCache({ storagePrefix: 'recorder-probe' }));

const seen = configSeeds();
expect(seen.length).toBe(1);
expect(seen[0].storagePrefix).toBe('recorder-probe');
});
});
18 changes: 16 additions & 2 deletions packages/react/src/hooks/useETagCache.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -208,9 +208,23 @@ export function useETagCache(userConfig: ETagCacheConfig = {}): ETagCacheResult
// every layout effect, ref attachment and paint, so the sole window deferred
// is the render phase — where `fetchWithETag` / `clearCache` and the other
// side effects are not callable anyway.
const configRef = useRef({ enabled, storage, storagePrefix, maxEntries, ttl });
//
// The object itself is built by `useMemo` rather than inline at the two
// places that consume it. `useRef({ ... })` evaluates its argument on EVERY
// render and keeps only the first result, so every later render allocated a
// five-key object that was thrown away (objectui#6817) — the same shape PR
// objectui#6796 repaired in `useSchemaPersistence`. Its identity is
// unobservable from outside the hook: the ref is private and every reader
// only reads fields off `.current`, so a memo React chooses to discard is
// harmless — it rebuilds an equal object, which is what every render used
// to do unconditionally.
const config = useMemo(
() => ({ enabled, storage, storagePrefix, maxEntries, ttl }),
[enabled, storage, storagePrefix, maxEntries, ttl],
);
const configRef = useRef(config);
useInsertionEffect(() => {
configRef.current = { enabled, storage, storagePrefix, maxEntries, ttl };
configRef.current = config;
});

// Hydrate memory cache from localStorage on mount
Expand Down
Loading