Skip to content

Fix hydration errors by moving image path resolution to build time #14024

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .cursor/hydration-error-refactor-guide.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
"sidecar": "yarn spotlight-sidecar",
"test": "vitest",
"test:ci": "vitest run",
"enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs"
"enforce-redirects": "node ./scripts/no-vercel-json-redirects.mjs",
"prebuild": "node scripts/copy-mdx-images.js"
},
"dependencies": {
"@ariakit/react": "^0.4.5",
Expand Down
93 changes: 93 additions & 0 deletions scripts/copy-mdx-images.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const fs = require('fs');
const path = require('path');
const glob = require('glob');

const DOCS_DIR = path.join(__dirname, '..', 'docs');
const PUBLIC_MDX_IMAGES = path.join(__dirname, '..', 'public', 'mdx-images');

function encodeImagePath(mdxFile, imagePath) {
// Get the absolute path to the image
const mdxDir = path.dirname(mdxFile);
const absImagePath = path.resolve(mdxDir, imagePath);
// Get the path relative to the docs root
let relPath = path.relative(DOCS_DIR, absImagePath);
// Replace path separators with dashes
relPath = relPath.replace(/[\\/]/g, '-');
return relPath;
}

function ensureDirExists(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, {recursive: true});
}
}

function copyImages() {
ensureDirExists(PUBLIC_MDX_IMAGES);

// Find all MDX files in docs/
const mdxFiles = glob.sync(path.join(DOCS_DIR, '**/*.mdx'));
console.log(`Found ${mdxFiles.length} MDX files`);

Check warning on line 30 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

// Match both ./img/ and ../img/ patterns
const imageRegex = /!\[[^\]]*\]\((\.\.?\/img\/[^")]+)\)/g;

let copied = 0;
mdxFiles.forEach(mdxFile => {
const content = fs.readFileSync(mdxFile, 'utf8');
const matches = [...content.matchAll(imageRegex)];
for (const match of matches) {
const imagePath = match[1];
const encodedName = encodeImagePath(mdxFile, imagePath);
const src = path.resolve(path.dirname(mdxFile), imagePath);
const dest = path.join(PUBLIC_MDX_IMAGES, encodedName);

if (fs.existsSync(src)) {
// Create the destination directory if it doesn't exist
const destDir = path.dirname(dest);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
fs.copyFileSync(src, dest);
copied++;
console.log(`Copied: ${src} -> ${dest}`);

Check warning on line 53 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
} else {
console.warn(`Image not found: ${src} (referenced in ${mdxFile})`);

Check warning on line 55 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
}
}
});

// Also copy all images from img directories directly
const imgDirs = glob.sync(path.join(DOCS_DIR, '**/img'));
console.log(`\nFound ${imgDirs.length} img directories:`);

Check warning on line 62 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
imgDirs.forEach(dir => console.log(`- ${path.relative(DOCS_DIR, dir)}`));

Check warning on line 63 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

imgDirs.forEach(imgDir => {
const files = fs.readdirSync(imgDir);
const imageFiles = files.filter(file => file.match(/\.(png|jpg|jpeg|gif)$/i));
console.log(

Check warning on line 68 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
`\nFound ${imageFiles.length} images in ${path.relative(DOCS_DIR, imgDir)}:`
);
imageFiles.forEach(file => console.log(`- ${file}`));

Check warning on line 71 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

imageFiles.forEach(file => {
const src = path.join(imgDir, file);
// The MDX plugin expects paths like /mdx-images/img-filename.png
const encodedName = `img-${file}`;
const dest = path.join(PUBLIC_MDX_IMAGES, encodedName);

if (!fs.existsSync(dest)) {
fs.copyFileSync(src, dest);
copied++;
console.log(`Copied: ${src} -> ${dest}`);

Check warning on line 82 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
} else {
console.log(`Skipped (already exists): ${src}`);

Check warning on line 84 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
}
});
});

console.log(`\nTotal images copied: ${copied}`);

Check warning on line 89 in scripts/copy-mdx-images.js

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
console.log(`Images are in: ${PUBLIC_MDX_IMAGES}`);
}

copyImages();
21 changes: 2 additions & 19 deletions src/components/docImage.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,20 @@
import path from 'path';

import Image from 'next/image';

import {serverContext} from 'sentry-docs/serverContext';

export default function DocImage({
src,
...props
}: Omit<React.HTMLProps<HTMLImageElement>, 'ref' | 'placeholder'>) {
const {path: pagePath} = serverContext();

if (!src) {
return null;
}

// Next.js Image component only supports images from the public folder
// or from a remote server with properly configured domain
// Remote images: render as <img>
if (src.startsWith('http')) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} {...props} />;
}

// If the image src is not an absolute URL, we assume it's a relative path
// and we prepend /mdx-images/ to it.
if (src.startsWith('./')) {
src = path.join('/mdx-images', src);
}
// account for the old way of doing things where the public folder structure mirrored the docs folder
else if (!src?.startsWith('/') && !src?.includes('://')) {
src = `/${pagePath.join('/')}/${src}`;
}

// parse the size from the URL hash (set by remark-image-size.js)
// Parse width/height from hash (set by remark-image-size.js)
const srcURL = new URL(src, 'https://example.com');
const imgPath = srcURL.pathname;
const [width, height] = srcURL.hash // #wxh
Expand Down
9 changes: 8 additions & 1 deletion src/mdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,14 @@ export async function getFileBySlug(slug: string) {
remarkGfm,
remarkDefList,
remarkFormatCodeBlocks,
[remarkImageSize, {sourceFolder: cwd, publicFolder: path.join(root, 'public')}],
[
remarkImageSize,
{
sourceFolder: cwd,
publicFolder: path.join(root, 'public'),
mdxFilePath: sourcePath,
},
],
remarkMdxImages,
remarkCodeTitles,
remarkCodeTabs,
Expand Down
40 changes: 28 additions & 12 deletions src/remark-image-size.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,40 @@ import getImageSize from 'image-size';
import {visit} from 'unist-util-visit';

/**
* appends the image size to the image url as a hash e.g. /img.png -> /img.png#100x100
* the size is consumed by docImage.tsx and passed down to next/image
* **this is a hack!**, there's probably a better way to set image node properties
* but adding a hash to the url seems like a very low risk way to do it 🙈
* Appends the image size to the image url as a hash e.g. /img.png -> /img.png#100x100
* and resolves all local image paths to /mdx-images/... at build time.
* Uses the full relative path from the MDX file to the image, encoded to avoid collisions.
* This ensures deterministic, absolute image paths for hydration safety.
*
* Requires options.mdxFilePath to be set to the absolute path of the current MDX file.
*/
export default function remarkImageSize(options) {
return tree =>
visit(tree, 'image', node => {
// don't process external images
// Remote images: leave as-is
if (node.url.startsWith('http')) {
return;
}
const fullImagePath = path.join(
// if the path starts with / it's a public asset, otherwise it's a relative path
node.url.startsWith('/') ? options.publicFolder : options.sourceFolder,
node.url
);
const imageSize = getImageSize(fullImagePath);
node.url = node.url + `#${imageSize.width}x${imageSize.height}`;

// Public images (start with /): ensure absolute
if (node.url.startsWith('/')) {
const fullImagePath = path.join(options.publicFolder, node.url);
const imageSize = getImageSize(fullImagePath);
// Leave the path as-is, just append the size hash
node.url = node.url + `#${imageSize.width}x${imageSize.height}`;
return;
}

// Local images (relative paths): resolve to /mdx-images/encoded-path-filename.ext
// Compute the absolute path to the image
const mdxDir = path.dirname(options.mdxFilePath);
const absImagePath = path.resolve(mdxDir, node.url);
const imageSize = getImageSize(absImagePath);

// Create a unique, encoded path for the image (e.g., docs-foo-bar-img-foo.png)
// Remove the workspace root and replace path separators with dashes
let relPath = path.relative(options.sourceFolder, absImagePath);
relPath = relPath.replace(/[\\/]/g, '-');
node.url = `/mdx-images/${relPath}#${imageSize.width}x${imageSize.height}`;
});
}
Loading