From 14c86bef1df0c9a390ea465d6454f84a2b223e2a Mon Sep 17 00:00:00 2001 From: Arunoda Susiripala Date: Wed, 15 Feb 2017 14:22:22 +0530 Subject: [PATCH] Introduce a simple prefetching solution (#957) * Implement a very simple prefetching solution. * Remove next-prefetcher. * Require 'whatwg-fetch' only in the client. * Use xhr in the code. * Use a simple fetching solution. * Fix 404 and xhr status issue. * Move the prefetching implementation to next/router. * Add deprecated warnning for next/prefetch * Run only 2 parellel prefetching request at a time. * Change xhr to jsonPageRes. * Improve the prefetching logic. * Add unit tests covering the Router.prefetch() * Update examples to use the new syntax. * Update docs. * Use execOnce() to manage warn printing. * Remove prefetcher building from the flyfile.js Because, we no longer use it. --- README.md | 27 ++- client/next-prefetcher.js | 107 ----------- .../with-prefetching/components/Header.js | 16 +- flyfile.js | 15 +- lib/link.js | 11 +- lib/prefetch.js | 159 +++-------------- lib/router/index.js | 2 +- lib/router/router.js | 166 ++++++++++-------- lib/utils.js | 28 +++ package.json | 1 + test/unit/router.test.js | 87 +++++++++ webpack.config.js | 35 ---- yarn.lock | 2 +- 13 files changed, 264 insertions(+), 392 deletions(-) delete mode 100644 client/next-prefetcher.js create mode 100644 test/unit/router.test.js delete mode 100644 webpack.config.js diff --git a/README.md b/README.md index 274a275d..87507cc6 100644 --- a/README.md +++ b/README.md @@ -344,40 +344,37 @@ Router.onRouteChangeError = (err, url) => {

-Next.js exposes a module that configures a `ServiceWorker` automatically to prefetch pages: `next/prefetch`. +Next.js has an API which allows you to prefetch pages. Since Next.js server-renders your pages, this allows all the future interaction paths of your app to be instant. Effectively Next.js gives you the great initial download performance of a _website_, with the ahead-of-time download capabilities of an _app_. [Read more](https://zeit.co/blog/next#anticipation-is-the-key-to-performance). +> With prefetching Next.js only download JS code. When the page is getting rendered, you may need to wait for the data. + #### With `` -You can substitute your usage of `` with the default export of `next/prefetch`. For example: +You can add `prefetch` prop to any `` and Next.js will prefetch those pages in the background. ```jsx -import Link from 'next/prefetch' +import Link from 'next/link' + // example header component export default () => ( ) ``` -When this higher-level `` component is first used, the `ServiceWorker` gets installed. To turn off prefetching on a per-`` basis, you can use the `prefetch` attribute: - -```jsx -Home -``` - #### Imperatively -Most needs are addressed by ``, but we also expose an imperative API for advanced usage: +Most prefetching needs are addressed by ``, but we also expose an imperative API for advanced usage: ```jsx -import { prefetch } from 'next/prefetch' +import Router from 'next/router' export default ({ url }) => (
setTimeout(() => url.pushTo('/dynamic'), 100) }> @@ -385,7 +382,7 @@ export default ({ url }) => ( { // but we can prefetch it! - prefetch('/dynamic') + Router.prefetch('/dynamic') }
) diff --git a/client/next-prefetcher.js b/client/next-prefetcher.js deleted file mode 100644 index 32a747cb..00000000 --- a/client/next-prefetcher.js +++ /dev/null @@ -1,107 +0,0 @@ -/* global self */ - -const CACHE_NAME = 'next-prefetcher-v1' -const log = () => {} - -self.addEventListener('install', () => { - log('Installing Next Prefetcher') -}) - -self.addEventListener('activate', (e) => { - log('Activated Next Prefetcher') - e.waitUntil(Promise.all([ - resetCache(), - notifyClients() - ])) -}) - -self.addEventListener('fetch', (e) => { - // bypass all requests except JSON pages. - if (!(/\/_next\/[^/]+\/pages\//.test(e.request.url))) return - - e.respondWith(getResponse(e.request)) -}) - -self.addEventListener('message', (e) => { - switch (e.data.action) { - case 'ADD_URL': { - log('CACHING ', e.data.url) - sendReply(e, cacheUrl(e.data.url)) - break - } - case 'RESET': { - log('RESET') - sendReply(e, resetCache()) - break - } - default: - console.error('Unknown action: ' + e.data.action) - } -}) - -function sendReply (e, result) { - const payload = { action: 'REPLY', actionType: e.data.action, replyFor: e.data.id } - result - .then((result) => { - payload.result = result - e.source.postMessage(payload) - }) - .catch((error) => { - payload.error = error.message - e.source.postMessage(payload) - }) -} - -function cacheUrl (url) { - const req = new self.Request(url, { - mode: 'no-cors', - headers: { - 'Accept': 'application/json' - } - }) - - return self.caches.open(CACHE_NAME) - .then((cache) => { - return self.fetch(req) - .then((res) => cache.put(req, res)) - }) -} - -function getResponse (req) { - return self.caches.open(CACHE_NAME) - .then((cache) => cache.match(req)) - .then((res) => { - if (res) { - log('CACHE HIT: ' + req.url) - return res - } else { - log('CACHE MISS: ' + req.url) - return self.fetch(req) - } - }) -} - -function resetCache () { - let cache - - return self.caches.open(CACHE_NAME) - .then((c) => { - cache = c - return cache.keys() - }) - .then(function (items) { - const deleteAll = items.map((item) => cache.delete(item)) - return Promise.all(deleteAll) - }) -} - -function notifyClients () { - return self.clients.claim() - .then(() => self.clients.matchAll()) - .then((clients) => { - const notifyAll = clients.map((client) => { - return client.postMessage({ action: 'NEXT_PREFETCHER_ACTIVATED' }) - }) - return Promise.all(notifyAll) - }) -} diff --git a/examples/with-prefetching/components/Header.js b/examples/with-prefetching/components/Header.js index daab0d0b..887eb562 100644 --- a/examples/with-prefetching/components/Header.js +++ b/examples/with-prefetching/components/Header.js @@ -1,23 +1,23 @@ -import Link, { prefetch } from 'next/prefetch' -import RegularLink from 'next/link' +import Router from 'next/router' +import Link from 'next/link' export default () => (
{ /* Prefetch using the declarative API */ } - + Home - + Features { /* we imperatively prefetch on hover */ } - - { prefetch('/about'); console.log('prefetching /about!') }}>About - + + { Router.prefetch('/about'); console.log('prefetching /about!') }}>About + - + Contact (NO-PREFETCHING) diff --git a/flyfile.js b/flyfile.js index 6de89a09..e254bd93 100644 --- a/flyfile.js +++ b/flyfile.js @@ -1,7 +1,6 @@ const webpack = require('webpack') const notifier = require('node-notifier') const childProcess = require('child_process') -const webpackConfig = require('./webpack.config') const isWindows = /^win/.test(process.platform) export async function compile(fly) { @@ -42,15 +41,7 @@ export async function copy(fly) { } export async function build(fly) { - await fly.serial(['copy', 'compile', 'prefetcher']) -} - -const compiler = webpack(webpackConfig) -export async function prefetcher(fly) { - compiler.run((err, stats) => { - if (err) throw err - notify('Built release prefetcher') - }) + await fly.serial(['copy', 'compile']) } export async function bench(fly) { @@ -70,8 +61,8 @@ export default async function (fly) { await fly.watch('bin/*', 'bin') await fly.watch('pages/**/*.js', 'copy') await fly.watch('server/**/*.js', 'server') - await fly.watch('client/**/*.js', ['client', 'prefetcher']) - await fly.watch('lib/**/*.js', ['lib', 'prefetcher']) + await fly.watch('client/**/*.js', ['client']) + await fly.watch('lib/**/*.js', ['lib']) } export async function release(fly) { diff --git a/lib/link.js b/lib/link.js index 873fe936..d046d2c3 100644 --- a/lib/link.js +++ b/lib/link.js @@ -62,7 +62,7 @@ export default class Link extends Component { } render () { - let { children } = this.props + let { children, prefetch } = this.props // Deprecated. Warning shown by propType check. If the childen provided is a string (example) we wrap it in an tag if (typeof children === 'string') { children = {children} @@ -79,11 +79,18 @@ export default class Link extends Component { props.href = this.props.as || this.props.href } + // Prefetch the JSON page if asked (only in the client) + if (prefetch) { + if (typeof window !== 'undefined') { + Router.prefetch(props.href) + } + } + return React.cloneElement(child, props) } } -export function isLocal (href) { +function isLocal (href) { const origin = getLocationOrigin() return !/^(https?:)?\/\//.test(href) || origin === href.substr(0, origin.length) diff --git a/lib/prefetch.js b/lib/prefetch.js index eef96335..0970ecfd 100644 --- a/lib/prefetch.js +++ b/lib/prefetch.js @@ -1,150 +1,33 @@ -/* global __NEXT_DATA__ */ - import React from 'react' -import Link, { isLocal } from './link' -import { parse as urlParse } from 'url' +import Link from './link' +import Router from './router' +import { warn, execOnce } from './utils' -class Messenger { - constructor () { - this.id = 0 - this.callacks = {} - this.serviceWorkerReadyCallbacks = [] - this.serviceWorkerState = null +const warnImperativePrefetch = execOnce(() => { + const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' + + '> Use "Router.prefetch(href)" instead.' + warn(message) +}) - navigator.serviceWorker.addEventListener('message', ({ data }) => { - if (data.action !== 'REPLY') return - if (this.callacks[data.replyFor]) { - this.callacks[data.replyFor](data) - } - }) +const wantLinkPrefetch = execOnce(() => { + const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' + + '> Use "" instead.' + warn(message) +}) - // Reset the cache always. - // Sometimes, there's an already running service worker with cached requests. - // If the app doesn't use any prefetch calls, `ensureInitialized` won't get - // called and cleanup resources. - // So, that's why we do this. - this._resetCache() - } - - send (payload) { - return new Promise((resolve, reject) => { - if (this.serviceWorkerState === 'REGISTERED') { - this._send(payload, handleCallback) - } else { - this.serviceWorkerReadyCallbacks.push(() => { - this._send(payload, handleCallback) - }) - } - - function handleCallback (err) { - if (err) return reject(err) - return resolve() - } - }) - } - - _send (payload, cb = () => {}) { - const id = this.id ++ - const newPayload = { ...payload, id } - - this.callacks[id] = (data) => { - if (data.error) { - cb(data.error) - } else { - cb(null, data.result) - } - - delete this.callacks[id] - } - - navigator.serviceWorker.controller.postMessage(newPayload) - } - - _resetCache (cb) { - const reset = () => { - this._send({ action: 'RESET' }, cb) - } - - if (navigator.serviceWorker.controller) { - reset() - } else { - navigator.serviceWorker.oncontrollerchange = reset - } - } - - ensureInitialized () { - if (this.serviceWorkerState) { - return - } - - this.serviceWorkerState = 'REGISTERING' - navigator.serviceWorker.register('/_next-prefetcher.js') - - // Reset the cache after registered - // We don't need to have any old caches since service workers lives beyond - // life time of the webpage. - // With this prefetching won't work 100% if multiple pages of the same app - // loads in the same browser in same time. - // Basically, cache will only have prefetched resourses for the last loaded - // page of a given app. - // We could mitigate this, when we add a hash to a every file we fetch. - this._resetCache((err) => { - if (err) throw err - this.serviceWorkerState = 'REGISTERED' - this.serviceWorkerReadyCallbacks.forEach(cb => cb()) - this.serviceWorkerReadyCallbacks = [] - }) - } -} - -function hasServiceWorkerSupport () { - return (typeof navigator !== 'undefined' && navigator.serviceWorker) -} - -const PREFETCHED_URLS = {} -let messenger - -if (hasServiceWorkerSupport()) { - messenger = new Messenger() -} - -function getPrefetchUrl (href) { - let { pathname } = urlParse(href) - const url = `/_next/${__NEXT_DATA__.buildId}/pages${pathname}` - - return url -} - -export async function prefetch (href) { - if (!hasServiceWorkerSupport()) return - if (!isLocal(href)) return - - // Register the service worker if it's not. - messenger.ensureInitialized() - - const url = getPrefetchUrl(href) - if (!PREFETCHED_URLS[url]) { - PREFETCHED_URLS[url] = messenger.send({ action: 'ADD_URL', url: url }) - } - - return PREFETCHED_URLS[url] -} - -export async function reloadIfPrefetched (href) { - const url = getPrefetchUrl(href) - if (!PREFETCHED_URLS[url]) return - - delete PREFETCHED_URLS[url] - await prefetch(href) +export function prefetch (href) { + warnImperativePrefetch() + return Router.prefetch(href) } export default class LinkPrefetch extends React.Component { render () { - const { href } = this.props - if (this.props.prefetch !== false) { - prefetch(href) + wantLinkPrefetch() + const props = { + ...this.props, + prefetch: this.props.prefetch === false ? this.props.prefetch : true } - return () + return () } } diff --git a/lib/router/index.js b/lib/router/index.js index 21ac3ca6..1df0ef8a 100644 --- a/lib/router/index.js +++ b/lib/router/index.js @@ -13,7 +13,7 @@ const SingletonRouter = { // Create public properties and methods of the router in the SingletonRouter const propertyFields = ['components', 'pathname', 'route', 'query'] -const coreMethodFields = ['push', 'replace', 'reload', 'back'] +const coreMethodFields = ['push', 'replace', 'reload', 'back', 'prefetch'] const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError'] propertyFields.forEach((field) => { diff --git a/lib/router/router.js b/lib/router/router.js index dc1cd919..8df8ffe1 100644 --- a/lib/router/router.js +++ b/lib/router/router.js @@ -1,11 +1,15 @@ -/* global __NEXT_DATA__ */ +/* global __NEXT_DATA__, fetch */ import { parse, format } from 'url' import evalScript from '../eval-script' import shallowEquals from '../shallow-equals' import { EventEmitter } from 'events' -import { reloadIfPrefetched } from '../prefetch' -import { loadGetInitialProps, getLocationOrigin } from '../utils' +import { loadGetInitialProps, LockManager, getLocationOrigin } from '../utils' + +// Add "fetch" polyfill for older browsers +if (typeof window !== 'undefined') { + require('whatwg-fetch') +} export default class Router extends EventEmitter { constructor (pathname, query, { Component, ErrorComponent, err } = {}) { @@ -15,6 +19,10 @@ export default class Router extends EventEmitter { // set up the component cache (by route keys) this.components = { [this.route]: { Component, err } } + // contain a map of response of prefetched routes + this.prefetchedRoutes = {} + this.prefetchingLockManager = new LockManager(2) + this.prefetchingRoutes = {} this.ErrorComponent = ErrorComponent this.pathname = pathname @@ -96,7 +104,8 @@ export default class Router extends EventEmitter { async reload (route) { delete this.components[route] - await reloadIfPrefetched(route) + delete this.prefetchedRoutes[route] + this.prefetchingRoutes[route] = 'IGNORE' if (route !== this.route) return @@ -184,8 +193,8 @@ export default class Router extends EventEmitter { const routeInfo = {} try { - const { Component, err, xhr } = routeInfo.data = await this.fetchComponent(route) - const ctx = { err, xhr, pathname, query } + const { Component, err, jsonPageRes } = routeInfo.data = await this.fetchComponent(route) + const ctx = { err, pathname, query, jsonPageRes } routeInfo.props = await this.getInitialProps(Component, ctx) } catch (err) { if (err.cancelled) { @@ -214,35 +223,74 @@ export default class Router extends EventEmitter { return this.pathname !== pathname || !shallowEquals(query, this.query) } + async prefetch (url) { + const { pathname } = parse(url) + const route = toRoute(pathname) + + const done = await this.prefetchingLockManager.get() + // It's possible for some other "prefetch" process + // to start prefetching the same route + // So, we should not fetch it again + if (this.prefetchingRoutes[route]) return done() + + // It's possible that, this is already prefetched. + // So, we should not fetch it again + if (this.prefetchedRoutes[route]) return done() + + // Mark as we are prefetching the route + this.prefetchingRoutes[route] = true + + const complete = () => { + delete this.prefetchingRoutes[route] + done() + } + + try { + const res = await this.fetchUrl(route) + // Router.relaod() process may ask us to ignore the current prefetching route + // In that case, we need to discard it + if (this.prefetchingRoutes[route] !== 'IGNORE') { + this.prefetchedRoutes[route] = res + } + complete() + } catch (ex) { + complete() + throw ex + } + } + async fetchComponent (route) { let data = this.components[route] - if (!data) { - let cancel + if (data) return data - data = await new Promise((resolve, reject) => { - this.componentLoadCancel = cancel = () => { - if (xhr.abort) { - xhr.abort() - const error = new Error('Fetching componenet cancelled') - error.cancelled = true - reject(error) - } - } - - const url = `/_next/${__NEXT_DATA__.buildId}/pages${route}` - const xhr = loadComponent(url, (err, data) => { - if (err) return reject(err) - resolve({ ...data, xhr }) - }) - }) - - if (cancel === this.componentLoadCancel) { - this.componentLoadCancel = null - } - - this.components[route] = data + let cancelled = false + const cancel = this.componentLoadCancel = function () { + cancelled = true } - return data + + let jsonPageRes = this.prefetchedRoutes[route] + if (!jsonPageRes) { + jsonPageRes = await this.fetchUrl(route) + } + + const jsonData = await jsonPageRes.json() + const newData = { + ...loadComponent(jsonData), + jsonPageRes + } + + if (cancelled) { + const error = new Error(`Abort fetching component for route: "${route}"`) + error.cancelled = true + throw error + } + + if (cancel === this.componentLoadCancel) { + this.componentLoadCancel = null + } + + this.components[route] = newData + return newData } async getInitialProps (Component, ctx) { @@ -265,6 +313,16 @@ export default class Router extends EventEmitter { return props } + async fetchUrl (route) { + const url = `/_next/${__NEXT_DATA__.buildId}/pages${route}` + const res = await fetch(url, { + method: 'GET', + headers: { 'Accept': 'application/json' } + }) + + return res + } + abortComponentLoad () { if (this.componentLoadCancel) { this.componentLoadCancel() @@ -292,47 +350,9 @@ function toRoute (path) { return path.replace(/\/$/, '') || '/' } -function loadComponent (url, fn) { - return loadJSON(url, (err, data) => { - if (err) return fn(err) +function loadComponent (jsonData) { + const module = evalScript(jsonData.component) + const Component = module.default || module - let module - try { - module = evalScript(data.component) - } catch (err) { - return fn(err) - } - - const Component = module.default || module - fn(null, { Component, err: data.err }) - }) -} - -function loadJSON (url, fn) { - const xhr = new window.XMLHttpRequest() - xhr.onload = () => { - let data - - try { - data = JSON.parse(xhr.responseText) - } catch (err) { - fn(new Error('Failed to load JSON for ' + url)) - return - } - - fn(null, data) - } - xhr.onerror = () => { - fn(new Error('XHR failed. Status: ' + xhr.status)) - } - xhr.onabort = () => { - const err = new Error('XHR aborted') - err.cancelled = true - fn(err) - } - xhr.open('GET', url) - xhr.setRequestHeader('Accept', 'application/json') - xhr.send() - - return xhr + return { Component, err: jsonData.err } } diff --git a/lib/utils.js b/lib/utils.js index d5ba6c5f..23a517f2 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -54,6 +54,34 @@ export async function loadGetInitialProps (Component, ctx) { return props } +export class LockManager { + constructor (maxLocks) { + this.maxLocks = maxLocks + this.clients = [] + this.runningLocks = 0 + } + + get () { + return new Promise((resolve) => { + this.clients.push(resolve) + this._giveLocksIfPossible() + }) + } + + _giveLocksIfPossible () { + if (this.runningLocks < this.maxLocks) { + const client = this.clients.shift() + if (!client) return + + this.runningLocks ++ + client(() => { + this.runningLocks -- + this._giveLocksIfPossible() + }) + } + } +} + export function getLocationOrigin () { const { protocol, hostname, port } = window.location return `${protocol}//${hostname}${port ? ':' + port : ''}` diff --git a/package.json b/package.json index a18e6ee3..faf20e0c 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "webpack": "2.2.1", "webpack-dev-middleware": "1.10.0", "webpack-hot-middleware": "2.16.1", + "whatwg-fetch": "^2.0.2", "write-file-webpack-plugin": "3.4.2" }, "devDependencies": { diff --git a/test/unit/router.test.js b/test/unit/router.test.js new file mode 100644 index 00000000..2b647aed --- /dev/null +++ b/test/unit/router.test.js @@ -0,0 +1,87 @@ +/* global describe, it, expect */ +import Router from '../../dist/lib/router/router' + +describe('Router', () => { + describe('.prefetch()', () => { + it('should prefetch a given page', async () => { + const router = new Router('/', {}) + const res = { aa: 'res' } + const route = 'routex' + router.fetchUrl = (r) => { + expect(r).toBe(route) + return Promise.resolve(res) + } + await router.prefetch(route) + + expect(router.prefetchedRoutes[route]).toBe(res) + }) + + it('should stop if it\'s prefetched already', async () => { + const router = new Router('/', {}) + const route = 'routex' + router.prefetchedRoutes[route] = { aa: 'res' } + router.fetchUrl = () => { throw new Error('Should not happen') } + await router.prefetch(route) + }) + + it('should stop if it\'s currently prefetching', async () => { + const router = new Router('/', {}) + const route = 'routex' + router.prefetchingRoutes[route] = true + router.fetchUrl = () => { throw new Error('Should not happen') } + await router.prefetch(route) + }) + + it('should ignore the response if it asked to do', async () => { + const router = new Router('/', {}) + const res = { aa: 'res' } + const route = 'routex' + + let called = false + router.fetchUrl = () => { + called = true + router.prefetchingRoutes[route] = 'IGNORE' + return Promise.resolve(res) + } + await router.prefetch(route) + + expect(router.prefetchedRoutes[route]).toBeUndefined() + expect(called).toBe(true) + }) + + it('should only run two jobs at a time', async () => { + const router = new Router('/', {}) + let count = 0 + + router.fetchUrl = () => { + count++ + return new Promise((resolve) => {}) + } + + router.prefetch('route1') + router.prefetch('route2') + router.prefetch('route3') + router.prefetch('route4') + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(count).toBe(2) + expect(Object.keys(router.prefetchingRoutes)).toEqual(['route1', 'route2']) + }) + + it('should run all the jobs', async () => { + const router = new Router('/', {}) + const routes = ['route1', 'route2', 'route3', 'route4'] + + router.fetchUrl = () => Promise.resolve({ aa: 'res' }) + + await router.prefetch(routes[0]) + await router.prefetch(routes[1]) + await router.prefetch(routes[2]) + await router.prefetch(routes[3]) + + expect(Object.keys(router.prefetchedRoutes)).toEqual(routes) + expect(Object.keys(router.prefetchingRoutes)).toEqual([]) + }) + }) +}) diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100644 index 750e272b..00000000 --- a/webpack.config.js +++ /dev/null @@ -1,35 +0,0 @@ -const resolve = require('path').resolve -const webpack = require('webpack') - -module.exports = { - entry: './client/next-prefetcher.js', - output: { - filename: 'next-prefetcher-bundle.js', - path: resolve(__dirname, 'dist/client') - }, - plugins: [ - new webpack.DefinePlugin({ - 'process.env': { - NODE_ENV: JSON.stringify('production') - } - }) - ], - module: { - rules: [{ - test: /\.js$/, - exclude: /node_modules/, - loader: 'babel-loader', - options: { - babelrc: false, - presets: [ - ['env', { - targets: { - // All browsers which supports service workers - browsers: ['chrome 49', 'firefox 49', 'opera 41'] - } - }] - ] - } - }] - } -} diff --git a/yarn.lock b/yarn.lock index 1fe50086..8fc37c15 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4835,7 +4835,7 @@ whatwg-encoding@^1.0.1: dependencies: iconv-lite "0.4.13" -whatwg-fetch@>=0.10.0: +whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz#fe294d1d89e36c5be8b3195057f2e4bc74fc980e"