Skip to content
Closed
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
34 changes: 34 additions & 0 deletions client/src/components/DataFiles/DataFilesTest.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { React, useEffect } from 'react';

import { LoadingSpinner, InlineMessage } from '_common';
import { useExternalStyles } from 'hooks/datafiles';

function DataTable() {
const { hostRef, styleStatus, renderWithStyles } = useExternalStyles();

useEffect(() => {
// IDEA: How about change these to single value like `isReadyToRenderWithStyles`?
if (hostRef.current && styleStatus.completed) {
renderWithStyles(
<div>
<h2>Browse Datasets</h2>
{styleStatus.failed.length > 0 && (
<InlineMessage type="warn">
Some styles failed to load. UI may look incorrect.
</InlineMessage>
)}
<p>Sample content.</p>
</div>
);
}
}, [hostRef.current, styleStatus, renderWithStyles]);


if (!styleStatus.completed) {
return <div ref={hostRef}><LoadingSpinner /></div>;
}

return <div ref={hostRef}></div>;
}

export default DataTable;
2 changes: 1 addition & 1 deletion client/src/components/DataFiles/index.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default } from './DataFiles';
export { default } from './DataFilesTest';
1 change: 1 addition & 0 deletions client/src/hooks/datafiles/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default as useSystemDisplayName } from './useSystemDisplayName';
export { default as useSelectedFiles } from './useSelectedFiles';
export { default as useExternalStyles } from './useExternalStyles';
export { default as useFileListing } from './useFileListing';
export { default as useSystems } from './useSystems';
export { default as useModal } from './useModal';
140 changes: 140 additions & 0 deletions client/src/hooks/datafiles/useExternalStyles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useEffect, useRef, useState, useLayoutEffect } from 'react';
import { createRoot } from 'react-dom/client';

const DEFAULT_STYLESHEETS = [
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600',
'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css',
];

function useExternalStyles(externalStylesheets = DEFAULT_STYLESHEETS) {
const hostRef = useRef();
const shadowRootRef = useRef();
const reactRootRef = useRef();
const [styleStatus, setStyleStatus] = useState({
loaded: [],
failed: [],
completed: false
});

function initializeShadowRoot() {
if (!hostRef.current) {
console.warn('No hostRef.current found');
return null;
}

if (!shadowRootRef.current) {
try {
shadowRootRef.current = hostRef.current.shadowRoot ||
hostRef.current.attachShadow({ mode: 'open' });
} catch (error) {
console.error('Failed to create shadow root:', error);
return null;
}
}

return shadowRootRef.current;
}

function cleanupDOM() {
if (shadowRootRef.current) {
const container = shadowRootRef.current.querySelector('div');
if (container) container.remove();
const links = shadowRootRef.current.querySelectorAll('link');
links.forEach(link => link.remove());
}
}

function cleanupReact() {
if (reactRootRef.current) {
reactRootRef.current.unmount();
reactRootRef.current = null;
}

setStyleStatus({
loaded: [],
failed: [],
completed: false
});
}

useLayoutEffect(() => {
return cleanupDOM;
}, []);

useEffect(() => {
const shadowRoot = initializeShadowRoot();
if (!shadowRoot) return;

loadStylesheets(shadowRoot);

return cleanupReact;
}, [externalStylesheets]);

function loadStylesheets(shadowRoot) {
const totalStylesheets = externalStylesheets.length;
if (totalStylesheets === 0) {
setStyleStatus({ loaded: [], failed: [], completed: true });
return;
}

const existingLinks = shadowRoot.querySelectorAll('link');
existingLinks.forEach(link => link.remove());

const loaded = [];
const failed = [];

function checkAllComplete() {
if (loaded.length + failed.length === totalStylesheets) {
setStyleStatus({
loaded,
failed,
completed: true
});
if (failed.length > 0) {
console.warn('Some stylesheets failed to load:', failed);
}
}
}

externalStylesheets.forEach(url => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.onload = () => {
loaded.push(url);
checkAllComplete();
};
link.onerror = () => {
failed.push(url);
checkAllComplete();
};
shadowRoot.appendChild(link);
});
}

function renderWithStyles(children) {
const shadowRoot = shadowRootRef.current;
if (!shadowRoot) {
console.warn('Cannot render: no shadow root');
return;
}

if (!reactRootRef.current) {
const container = document.createElement('div');
shadowRoot.appendChild(container);
reactRootRef.current = createRoot(container);
}

reactRootRef.current.render(children);
}


return {
hostRef,
areStylesLoaded: styleStatus.completed,
styleStatus,
renderWithStyles,
};
}

export default useExternalStyles;
194 changes: 194 additions & 0 deletions client/src/hooks/datafiles/useExternalStyles.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React from 'react';
import { render, act, cleanup } from '@testing-library/react';
import { vi } from 'vitest';
import useExternalStyles from './useExternalStyles';

const TEST_STYLESHEETS = [
'test://style1.css',
'test://style2.css'
];

describe('useExternalStyles', () => {
beforeEach(() => {
// Mock the link.onload event to fire immediately
const originalCreateElement = document.createElement;
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
const element = originalCreateElement.call(document, tagName);
if (tagName === 'link') {
// Simulate successful load immediately
setTimeout(() => element.onload(), 0);
}
return element;
});
});

afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});

test('useExternalStyles hook initializes correctly', () => {
let hookResult;
const TestComponent = () => {
hookResult = useExternalStyles(TEST_STYLESHEETS);
return <div ref={hookResult.hostRef}>Test Content</div>;
};

act(() => {
render(<TestComponent />);
});

expect(hookResult).toBeDefined();
expect(hookResult.hostRef).toBeDefined();
expect(hookResult.areStylesLoaded).toBeDefined();
expect(hookResult.styleStatus).toBeDefined();
expect(hookResult.renderWithStyles).toBeDefined();
});

test('handles rapid unmount and remount during hot reload', async () => {
let hookResult;
const TestComponent = () => {
hookResult = useExternalStyles(TEST_STYLESHEETS);
return <div ref={hookResult.hostRef}>Test Content</div>;
};

// Initial render
const { unmount } = render(<TestComponent />);

// Wait for initial style loading
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});

// Unmount
act(() => {
unmount();
});

// Remount immediately
act(() => {
render(<TestComponent />);
});

// Wait for style loading after remount
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});

expect(hookResult.styleStatus.completed).toBe(true);
expect(hookResult.styleStatus.loaded).toEqual(TEST_STYLESHEETS);
});

test('cleanup happens properly during unmount', () => {
let hookResult;
const unmountSpy = vi.fn();

const TestComponent = () => {
hookResult = useExternalStyles(TEST_STYLESHEETS);
React.useEffect(() => {
return () => unmountSpy();
}, []);
return <div ref={hookResult.hostRef}>Test Content</div>;
};

const { unmount } = render(<TestComponent />);

act(() => {
hookResult.renderWithStyles(<div>Test</div>);
});

act(() => {
unmount();
});

expect(unmountSpy).toHaveBeenCalled();
});

test('handles style loading errors gracefully', async () => {
// Mock createElement to simulate a failed load
const originalCreateElement = document.createElement;
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
const element = originalCreateElement.call(document, tagName);
if (tagName === 'link') {
// Simulate failed load immediately
setTimeout(() => element.onerror(new Error('Failed to load')), 0);
}
return element;
});

let hookResult;
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

const TestComponent = () => {
hookResult = useExternalStyles(TEST_STYLESHEETS);
return <div ref={hookResult.hostRef}>Test Content</div>;
};

render(<TestComponent />);

// Wait for error handling
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});

expect(hookResult.styleStatus.completed).toBe(true);
expect(hookResult.styleStatus.failed).toEqual(TEST_STYLESHEETS);
errorSpy.mockRestore();
});

test('handles concurrent renders during hot reload', async () => {
let hookResult;
const TestComponent = () => {
hookResult = useExternalStyles(TEST_STYLESHEETS);
return <div ref={hookResult.hostRef}>Test Content</div>;
};

const { rerender } = render(<TestComponent />);

// Wait for initial style loading
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});

// Trigger multiple rerenders
for (let i = 0; i < 3; i++) {
act(() => {
rerender(<TestComponent key={i} />);
});
}

// Wait for final style loading
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});

expect(hookResult.styleStatus.completed).toBe(true);
expect(hookResult.styleStatus.loaded).toEqual(TEST_STYLESHEETS);
});

test('handles unmount during style loading', async () => {
let hookResult;
const TestComponent = () => {
hookResult = useExternalStyles(TEST_STYLESHEETS);
return <div ref={hookResult.hostRef}>Test Content</div>;
};

const { unmount } = render(<TestComponent />);

// Start style loading
act(() => {
hookResult.renderWithStyles(<div>Test</div>);
});

// Unmount during loading
act(() => {
unmount();
});

// Should be able to remount without errors
act(() => {
render(<TestComponent />);
});
});
});
Loading